Skip to Content
Getting Started

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:

  1. Minimal editor — useLocalEditor, in-memory, no backend.
  2. Add features — plugins + UI extensions, à la carte.
  3. Add persistence or collaboration — swap the mount hook.

Install

npm install @stesura/editor-react @stesura/core

Into an existing app

  • Peers. npm installs them for you: react, react-dom, the four ProseMirror singletons (prosemirror-model, -state, -transform, -view) and react-reconciler. The singletons are peers so your app and every @stesura/* package share one copy. Don’t add overrides for them. With legacy-peer-deps (or a pnpm setup without auto-installed peers), install them yourself.
  • react-reconciler follows React: 0.29 for React 18.3, 0.31 for 19.0, 0.32 for 19.1, 0.33 for 19.2.
  • No Tailwind needed. StesuraEditor imports 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:

ImportWhen
@stesura/ui/preflight.cssYour 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.cssReal-time collaboration: remote cursors.
@stesura/pdf-viewer-react/stylesEmbedded 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’s text is 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:

PropTypePurpose
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 wantHookDocs
In-memory (previews, forms, tests)useLocalEditorthis page
Offline persistence, single browseruseLocalCollabEditor (IndexedDB + BroadcastChannel)Local
Real-time multi-useruseCollabEditor + a ~10-line backend on your Postgres + RedisPitter Patter
Multi-document roomsRoomProvider + useRoomManifestRooms

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

Last updated on