Pages
Notion-style sub-pages — a sub-page block, inline page links, and a host-owned page store.
A page is two things: an editor surface (sub-page blocks, links between pages) and an application (storage, a page tree, routing, permissions). The library owns the first. You own the second.
| The library owns | You own |
|---|---|
subPage block and pageLink inline node (only a pageId) | Persistence of titles, icons, covers, tree |
/page, /link to page, and page entries in the @ menu | Routing — the library calls onNavigate |
| Reporting that a sub-page block left or entered the document | What to do about it (trash, restore) |
Markdown export/import of the nodes, collectPageRefs | The backlinks index and the Y.Doc per page |
Titles never live in the document, so a rename shows in every block and link at once. pages is
opt-in; without it no page node is registered. Try it in the
playground.
Writing a PageStore
Prop
Type
Two rules matter. peek is a synchronous cache read and must return a stable object until
that page changes — hooks compare by identity. create and update must notify
subscribe listeners, or nothing re-renders. A minimal in-memory store:
import type { PageMeta, PageStore } from "@slash-editor/core";
export function createMemoryPageStore(initial: PageMeta[] = []): PageStore {
const pages = new Map(initial.map((page) => [page.id, page]));
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
return {
create({ parentId, title = "" }) {
const page: PageMeta = { id: crypto.randomUUID(), parentId, title };
pages.set(page.id, page);
notify();
return page;
},
update(id, patch) {
const page = pages.get(id);
if (!page) return;
// A new object, so `peek` changes identity exactly when the page does.
pages.set(id, { ...page, ...patch });
notify();
},
peek: (id) => pages.get(id),
load: async (id) => pages.get(id),
listChildren: (parentId) => [...pages.values()].filter((page) => page.parentId === parentId),
search: (query) =>
[...pages.values()].filter(
(page) => !page.trashed && page.title.toLowerCase().includes(query.toLowerCase()),
),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}A real store fills its cache from load, notifies when a collaborator changes a page, and may
make every method async except peek and subscribe.
Wiring the kit
import { createBlockKit } from "@slash-editor/core";
const extensions = createBlockKit({
pages: { store, currentPageId: pageId, onNavigate: (id) => router.push(`/p/${id}`) },
});Prop
Type
Extensions are latched once per editor instance, so remount the editor per page with
key={pageId} and pass stable callbacks (memoize onNavigate and friends) — later changes to
the options are not picked up.
function PageEditor({ pageId }: { pageId: string }) {
const onNavigate = useCallback((id: string) => router.push(`/p/${id}`), []);
const editor = useSlashEditor({
blockKit: { pages: { store, currentPageId: pageId, onNavigate } },
});
return <EditorContent editor={editor} />;
}
<PageEditor key={pageId} pageId={pageId} />;Rendering the nodes
Core emits only data-page-id elements. Attach the registry views from
Pages components through the nodeViews option — it exists so you do not
re-register subPage/pageLink, which Tiptap would reject as a name collision:
import { ReactNodeViewRenderer } from "@slash-editor/react";
import { PageLinkNodeView, SubPageNodeView } from "@/components/nodes/page-node-views.tsx";
const pages = {
store,
currentPageId: pageId,
onNavigate,
nodeViews: {
subPage: ReactNodeViewRenderer(SubPageNodeView),
pageLink: ReactNodeViewRenderer(PageLinkNodeView),
},
};Creating and navigating
/page calls store.create({ parentId: currentPageId }), inserts a subPage block, and calls
onNavigate with the new id. If create rejects, nothing is inserted and onError runs.
Clicking a sub-page block, or pressing Enter on it, calls onNavigate; so does a page link.
Linking to a page
/link to page and the @ menu insert a pageLink. With pages set, the kit merges
store.search(query) results into the mention items — after your own — as
entries with kind: "page". With pages set and no mention option, the @ menu lists pages
alone. Selecting a kind: "page" item inserts a pageLink, not a mention.
Deleting and restoring
Deleting a sub-page block does not delete the page. The library reports it; you decide:
pages: {
store,
currentPageId: pageId,
onNavigate,
onSubPagesDetached: (ids) => ids.forEach((id) => trash(id)), // set `trashed: true`
onSubPagesAttached: (ids) => ids.forEach((id) => restore(id)), // undo, paste
}Only the local edit reports: remote transactions are skipped, so the collaborator who deleted
the block is the one who trashes the page. A Yjs undo/redo counts as local. Counting is net per
dispatch, so moving a block (moveBlock) or cut-and-paste within one transaction fires nothing.
The node view shows a "missing / in trash" state for a trashed page and never removes the node.
Trashing and reordering
PageStore has two optional methods for the tree. The library never calls them — they are the
shape your own UI (and onSubPagesDetached/onSubPagesAttached) calls into:
store.setTrashed?.(["a1b2"], true); // restore with `false`; unknown ids are ignored
store.move?.("a1b2", { parentId: "c3d4", index: 0 }); // re-parent and/or reorderSibling order is whatever listChildren(parentId) returns, trashed pages included, and
usePageTree renders it as-is. move takes index among that list once the page itself is
removed from it (like splice, clamped); moving a page into itself or a descendant is a no-op.
Both must notify subscribe listeners so the tree refetches. createDemoPageStore in the
registry is a working reference, ordering siblings with an order list.
One parent per page
A page has exactly one parent. Duplicating a sub-page block — or pasting one whose pageId is
already in the document, or whose store.peek(id).parentId is not currentPageId — converts it
into a paragraph holding a pageLink. Cut and paste within the same page keeps the block and
reports detach then attach.
Backlinks
collectPageRefs is pure and finds references at any depth (toggles, columns, table cells). Call it
on save, update your index, and serve it through store.backlinks:
import { collectPageRefs } from "@slash-editor/core";
function onSave(pageId: string, editor: Editor) {
const { subPages, links } = collectPageRefs(editor.getJSON());
index.set(pageId, new Set([...subPages, ...links])); // pageId -> pages it references
}
// in the store:
backlinks: (id) => [...index].filter(([, refs]) => refs.has(id)).map(([source]) => source),useBacklinks and PageBacklinks read this; trashed pages are left out.
Collaboration: one Y.Doc per page
Give each page its own document and provider, and tear them down when the page unmounts. Create
them in an effect, not useMemo: React StrictMode mounts, unmounts and remounts, so the effect's
cleanup destroys the first provider and the second one is the one the editor keeps. Titles and the
tree reach other clients through store.subscribe, not the Y.Doc.
function PageRoom({ pageId }: { pageId: string }) {
const [session, setSession] = useState<{ document: Y.Doc; provider: HocuspocusProvider } | null>(
null,
);
useEffect(() => {
const document = new Y.Doc();
const provider = new HocuspocusProvider({ url, name: `page:${pageId}`, document });
setSession({ document, provider });
return () => {
provider.destroy();
document.destroy();
setSession(null);
};
}, [pageId]);
// The editor latches its extensions, so it only mounts once the session exists.
return session ? <PageEditor key={pageId} pageId={pageId} session={session} /> : null;
}
function PageEditor({ pageId, session }: { pageId: string; session: Session }) {
useSlashEditor({
blockKit: {
collaboration: { document: session.document, provider: session.provider, user },
pages: { store, currentPageId: pageId, onNavigate },
},
});
// …
}Provider setup is covered in the collaboration guide.
Markdown
With the kit's pages option, export writes a marker plus a link:
<!-- slash:subPage {"pageId":"abc"} -->[Roadmap](#abc)The title comes from store.peek, the href from resolveHref (default #<pageId>). Import keeps
only pageId; the store stays the source of truth for titles. Without the kit's pages option,
export falls back to the marker with the page id as link text. See the
markdown guide.