Getting Started
StesuraEditor renders the whole editor: toolbar, document canvas, floating
menus and side panels. You own the EditorState and pass it in with a
dispatchTransaction; a mount hook produces both. Every mount hook returns the
same { editorState, dispatch, pluginFactory, schema }, so you can start
in memory and swap the hook later without touching the editor, the plugins or
the UI extensions.
This page gets a minimal editor on screen, then grows it:
- Minimal editor —
useLocalEditor, in-memory, no backend. - Add features — plugins + UI extensions, à la carte.
- Add persistence or collaboration — swap the mount hook.
Install
npm install @stesura/editor-react @stesura/coreInto an existing app
- Peers. npm installs them for you:
react,react-dom, the four ProseMirror singletons (prosemirror-model,-state,-transform,-view) andreact-reconciler. The singletons are peers so your app and every@stesura/*package share one copy. Don’t addoverridesfor them. Withlegacy-peer-deps(or a pnpm setup without auto-installed peers), install them yourself. react-reconcilerfollows React:0.29for React 18.3,0.31for 19.0,0.32for 19.1,0.33for 19.2.- No Tailwind needed.
StesuraEditorimports its precompiled stylesheets (see Stylesheets).
The package scope is @stesura/* inside this monorepo. Versions of all
@stesura/* packages move together. In a Next.js app, list every
@stesura/* package you import (plus their @stesura/* dependencies) in
transpilePackages — see apps/testbed/next.config.mjs for a working list.
Next.js / SSR
StesuraEditor uses browser APIs and cannot be server-rendered. Import it
with dynamic and ssr: false, as in the minimal editor,
and keep any component that renders it a Client Component ("use client").
EditorLoadingState is SSR-safe and can be imported statically for the
loading fallback.
Stylesheets
StesuraEditor imports its stylesheets itself, so your bundler must accept CSS
imports from node_modules (Next.js does):
@stesura/core/styles: the document.@stesura/ui/styles.css: the editor UI and its tokens (--primary, …), precompiled and scoped to.stesura, so it can’t touch your page. Override tokens on.stesura, not:root(scoping).@stesura/fonts/fonts.css: the bundled fonts.
The comments UI imports its own. Import the rest once, in your app:
| Import | When |
|---|---|
@stesura/ui/preflight.css | Your app doesn’t load Tailwind: the UI assumes its preflight resets. Scoped like styles.css, so it resets only the editor. |
@stesura/collab-adapter/styles.css | Real-time collaboration: remote cursors. |
@stesura/pdf-viewer-react/styles | Embedded PDFs rendered with createPdfViewer. |
Don’t import @stesura/pdf-export-client/print.css: the export page’s shell
loads it, and its @page rule would also apply to your app’s own printing.
Fonts
The editor’s fonts ship inside @stesura/fonts as .woff2 files, referenced
from fonts.css by relative url()s. Your bundler emits them as hashed static
assets and serves them from your own origin: no Google Fonts request, nothing
to configure, and the PDF export uses the same files. A file downloads only
when text needs it (unicode-range), so a plain Arial/Arimo document fetches
one small file.
This needs a bundler that processes url() in imported CSS (Next.js, Vite and
webpack do). If yours doesn’t (plain tsc, Rollup without a CSS plugin, CSS
injected into a shadow root as a string), copy
node_modules/@stesura/fonts/assets to a folder your server serves, keeping its
layout, and link fonts.css from there:
<link rel="stylesheet" href="/stesura-fonts/fonts.css" />A minimal editor
useLocalEditor owns an in-memory EditorState — no persistence, no backend.
This runs as-is (live → ):
"use client";
import { EditorLoadingState, useLocalEditor, StesuraUserProvider } from "@stesura/editor-react";
import dynamic from "next/dynamic";
const StesuraEditor = dynamic(
() => import("@stesura/editor-react").then((m) => m.StesuraEditor),
{ ssr: false, loading: () => <EditorLoadingState /> }
);
const currentUser = { id: "user-1", name: "Ada" };
const Editor = () => {
const { editorState, dispatch, pluginFactory, schema } = useLocalEditor();
return (
<div className="h-full w-full">
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
pluginFactory={pluginFactory}
/>
</div>
);
};
export default function Page() {
return (
<StesuraUserProvider currentUser={currentUser}>
<Editor />
</StesuraUserProvider>
);
}StesuraUserProvider is required: the mount hooks read currentUser from it
and throw without it.
useLocalEditor also accepts initialContent (HTML string, ProseMirror
Node or JSON), a custom schema, extraPlugins and a transactionModifier
(see the next section and the API reference). schema
and initialContent are read once at mount; to switch documents, remount with
a key. If you pass an editorId, give StesuraEditor the same value as
field.
HTML content is browser-only. HTML strings are parsed with the DOM, so they throw in server code (route handlers, server actions). To seed documents server-side, pass ProseMirror JSON, or build heading/paragraph content with
createDocContent(schema, blocks)from@stesura/core/helpers. A block’stextis a string, or runs with mark names for formatting:import { createDocContent } from "@stesura/core/helpers"; const doc = createDocContent(schema, [ { type: "heading", level: 1, text: "Minutes" }, { type: "paragraph", text: [{ text: "Attendees: ", marks: ["strong"] }, { text: "…" }] }, ]);
Adding features
Features ship as package pairs: a plugins package (document behaviour) and a
-react package (the UI extension with its toolbar items, panels and
decorations). Add each feature in two places: extraPlugins on the mount
hook, uiExtensions on the editor.
"use client";
import { EditorLoadingState, useLocalEditor } from "@stesura/editor-react";
import { commentsPlugins } from "@stesura/comments";
import { commentsUiExtension } from "@stesura/comments-react";
import { paginationPlugins } from "@stesura/pagination";
import { paginationUiExtension } from "@stesura/pagination-react";
import { trackChangesPlugin, trackChangesTransactionModifier } from "@stesura/track-changes";
import { trackChangesUIExtension } from "@stesura/track-changes-react";
import dynamic from "next/dynamic";
const StesuraEditor = dynamic(
() => import("@stesura/editor-react").then((m) => m.StesuraEditor),
{ ssr: false, loading: () => <EditorLoadingState /> }
);
// Module scope — the array must be referentially stable across renders.
const uiExtensions = [
trackChangesUIExtension,
paginationUiExtension,
commentsUiExtension,
];
const Editor = () => {
const { editorState, dispatch, pluginFactory, schema } = useLocalEditor({
transactionModifier: trackChangesTransactionModifier,
extraPlugins: () => [
trackChangesPlugin(),
...paginationPlugins(),
...commentsPlugins(),
],
});
return (
<StesuraEditor
state={editorState}
dispatchTransaction={dispatch}
schema={schema}
pluginFactory={pluginFactory}
// The hook's modifier covers the main editor; this one covers sub-editors.
transactionModifier={trackChangesTransactionModifier}
uiExtensions={uiExtensions}
/>
);
};The same pattern covers references, proofread, DOCX import/export, and PDF export. See UI extensions for what an extension can contribute.
Users, mentions, uploads
StesuraUserProvider (from @stesura/editor-react) supplies
identity to every editor in its subtree:
| Prop | Type | Purpose |
|---|---|---|
currentUser | { id, name?, avatar?, color? } | Author of comments and tracked changes, collab presence identity. |
resolveUsers | ({ userIds }) => Promise<{ name, avatar? }[]> | Resolves ids to display data (comments, mentions, cursors), index-aligned with userIds. |
resolveMentionSuggestions | ({ text }) => Promise<string[]> | User ids matching an @ mention query. Without it the mention dropdown never opens. |
Uploads go through the onFileUpload prop on StesuraEditor:
(file, type, contentType?) => Promise<string | undefined>, resolving to the
URL to embed. See File & Image Uploads.
Persistence and collaboration
Swap the mount hook; everything else stays:
| You want | Hook | Docs |
|---|---|---|
| In-memory (previews, forms, tests) | useLocalEditor | this page |
| Offline persistence, single browser | useLocalCollabEditor (IndexedDB + BroadcastChannel) | Local |
| Real-time multi-user | useCollabEditor + a ~10-line backend on your Postgres + Redis | Pitter Patter |
| Multi-document rooms | RoomProvider + useRoomManifest | Rooms |
Comments work at every rung: no-op locally,
LocalCommentProvider for IndexedDB, and server-backed threads under
useCollabEditor — the UI is identical.
For a complete, realistic reference — Supabase auth, per-room membership
enforced in the collab routes’ authorize, document dashboard, and the fully
featured editor — read apps/testbed in this repo.
Next steps
- Collaboration — backend comparison and architecture.
- Plugins — what the plugin factory composes.
- Customizing the Toolbar and Customizing the Menus.
- Extending the schema.
- Live examples — every mount running in the demo app.