Documentation

Widgets

Editable pages

Make a VitNode page editable in the browser. Declare its widget zones once with defineEditablePage, register it with a moderator permission, and render it with PageWidgets so moderators get Edit widgets in the user menu.

An editable page is an ordinary page whose content zones moderators arrange on the page itself: Edit widgets in the user menu, drag, Save. You declare the zones once; core stores the layout, checks the permission on every save and draws the menu entry. This guide makes the site-notes plugin's notes page editable, with a zone above and below the list of notes.

Core providesYou write
The editor, loaded on the first click, and the Edit widgets entryThe page declaration: id, zones, permission
GET and PUT /pages/layout, and the core_page_layouts tableTwo lines of registration in buildApiPlugin
The permission check on every save, and conflict detectionOne line in the route loader, and PageWidgets

Before you begin

  • A plugin page to make editable. This guide uses /site-notes/notes from Plugin page.
  • An account with full access, such as the first account, to try the editor. Anyone else needs the moderator permission this guide adds.

Declare the page

Create src/editable-pages/notes-page.ts. It names the page, its zones and who may arrange them:

plugins/site-notes/src/editable-pages/notes-page.ts
import { defineEditablePage } from "@vitnode/core/content/editor";

import { manifest } from "@/manifest.gen";

export const notesPage = defineEditablePage({
  id: "site-notes:notes",

  permission: {
    plugin: manifest.id,
    module: "widgets",
    permission: "can_edit",
  },

  zones: {
    "before-notes": { allowed: ["core:*"], max: 10 },
    "after-notes": { allowed: ["core:*"], max: 10 },
  },
});
OptionRequiredDescription
idyesThe page's permanent identity, plugin:page. The stored layout is keyed by it, so write it by hand and never change it.
permissionyesThe moderator permission that unlocks editing. Set plugin. The server falls back to the registering plugin, but the browser cannot, and PageWidgets never offers editing without it.
zonesyesOne entry per zone, keyed by a page-local zone id. At least one.

Each zone takes allowed (the allowlist, default "*"), max (default 200, counting widgets, not areas), min, and default, the widgets the zone shows until somebody saves it, written as real stored nodes like { id, type, data }. Without default, a zone starts empty and renders nothing.

The file imports nothing heavy, so the API and the page can both import it.

Register it

In src/config.api.ts, add two keys to the existing buildApiPlugin call: the page in editablePages, and its permission under permissionStaff.moderator. After Plugin page the call looks like this; keep your modules as they are:

plugins/site-notes/src/config.api.ts (excerpt)
import { notesPage } from "./editable-pages/notes-page"; 

export const siteNotesApiPlugin = () =>
  buildApiPlugin({
    manifest,
    modules: [helloModule, notesModule],
    editablePages: [notesPage], 
    permissionStaff: {
      moderator: { widgets: ["can_edit"] },
    },
  });

If your call already has permissionStaff, for example admin: { notes: ["can_create"] } from the Permissions lesson, add the moderator key inside it instead of writing a second permissionStaff.

Then label the permission in src/locales/en.json. Add the two permission keys at the root, next to the site-notes object, and a title inside that object if it has none yet; the AdminCP shows it as the plugin's name. Keep the keys you already have:

plugins/site-notes/src/locales/en.json (excerpt)
{
  "site-notes": {
    "title": "Site Notes"
  },
  "site-notes:widgets": "Widgets",
  "site-notes:widgets:can_edit": "Edit page widgets"
}

Without editablePages, reading the layout answers 404. A page whose permission is missing from permissionStaff.moderator, or a page id registered twice, stops the API at startup with a message naming the fix.

Load the layout in the route

The loader warms the page's layout and the viewer's permissions, so the zones render on the server. loadPageWidgets needs the query client, which defineRoute from @vitnode/core/tanstack/plugin-routes hands the loader. In src/pages/notes-page.tsx, replace definePluginRoute:

plugins/site-notes/src/pages/notes-page.tsx (excerpt)
import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; 
import { loadPageWidgets } from "@vitnode/core/tanstack/widgets"; 

import { notesPage } from "@/editable-pages/notes-page"; 

export const route = defineRoute<NotesPageData>({
  load: async ({ context }) => {
    const [notes] = await Promise.all([
      loadNotes(),
      loadPageWidgets(context.queryClient, notesPage), 
    ]);

    return notes;
  },
});

loadPageWidgets never throws. If the layout cannot be read, the page still renders, without its zones and without Edit widgets, so nobody can save empty zones over the real ones.

Wrap the zones in PageWidgets

In the same file, wrap the page in PageWidgets and put a PageWidgetsZone wherever a declared zone goes:

plugins/site-notes/src/pages/notes-page.tsx (excerpt)
import { PageWidgets, PageWidgetsZone } from "@vitnode/core/tanstack/widgets"; 

const NotesPage = ({ loaderData: notes }: PluginRoutePageProps<NotesPageData>) => {
  const t = useTranslations("site-notes.notes");

  return (
    <PageWidgets page={notesPage}>
      <main className="container mx-auto flex max-w-2xl flex-col gap-6 p-4">
        <h1 className="text-2xl font-semibold tracking-tight text-balance">{t("title")}</h1>

        <PageWidgetsZone id="before-notes" />

        {/* the list of notes, unchanged */}

        <PageWidgetsZone id="after-notes" />
      </main>
    </PageWidgets>
  );
};

PageWidgets reads the layout and the permissions the loader warmed, builds the save adapter and keeps the cache up to date after each save. Pass openEditing to honour an ?edit=true link; it only opens the editor for somebody who already may edit.

PageWidgetsZone renders a ContentZone with a flex flex-col gap-6 wrapper and the default widget registry, which always holds core's widgets. If a zone allows your plugin's own widgets, render <ContentZone id="before-notes" registry={blocksRegistry} /> inside PageWidgets instead, with a registry from createBlockRegistry, so the server and the browser render the same widgets.

Grant the permission

Members with full access hold every permission already. For anyone else, open Staff → Moderators in the AdminCP (/admin/core/staff/moderators), choose Add Moderator, pick a role or a user and choose Add member. Then edit that moderator's permissions, choose Site Notes and tick Edit page widgets under Widgets.

Check the result

Sign in with an account that may edit and open http://localhost:3000/site-notes/notes. Nothing looks different yet, because both zones are empty and render nothing. Open the user menu and choose Edit widgets. The Widgets sidebar slides in and each zone shows Drag and drop widgets here with an Add widget button. Add a Text widget, fill in its body and choose Save; a Page saved toast confirms it. Finish editing, reload, and the text is still there.

The site-notes notes page in edit mode right after saving: a Text widget in the Before notes zone above the two sample notes, the empty After notes zone with Drag and drop widgets here, the widget's body in the editor sidebar, a Page saved toast and Saved in the sidebar footer

Signed out, the page shows the saved widgets and no menu entry, and no editor code is downloaded. Visual editor covers everything a moderator can do from here.

Core does this too

Core's account settings page, /settings, is an editable page with two zones, header and footer, that ship empty and accept every installed widget, up to ten each. It is built exactly like this guide, with loadPageWidgets in its loader and PageWidgetsZone for both zones, and is edited with the moderator permission Rearrange widgets on core's pages.

  • Custom loader: load and save the layout yourself, with EditablePage, when PageWidgets does not fit.
  • Saving layouts: the two routes, storage, limits, refusals and what happens when two moderators save.
  • Restrict widgets: what each zone accepts, and narrowing it at the call site.