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 editoruseLocalEditor, 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

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 keep any component that renders it a Client Component ("use client"):

import dynamic from "next/dynamic"; import { EditorLoadingState } from "@stesura/editor-react"; const StesuraEditor = dynamic( () => import("@stesura/editor-react").then((m) => m.StesuraEditor), { ssr: false, loading: () => <EditorLoadingState /> } );

EditorLoadingState is SSR-safe and can be imported statically for the fallback.

Editor fonts on cold loads (optional)

By default the editor injects its Google Fonts stylesheet at mount (loadEditorFonts, called by the mount hooks). That works everywhere with zero setup, but on a cold cache it serializes: JS boots → editor mounts → fonts CSS downloads → font files download. The document’s text — usually the largest paint — waits for the end of that chain, and so does the first pagination pass (it gates on document.fonts.ready).

If first-load performance matters, render the stylesheet link yourself in your SSR’d document head. loadEditorFonts detects it by id and steps aside:

// app/layout.tsx import { EDITOR_FONTS_LINK_ID, EDITOR_FONTS_PRECONNECT_ORIGINS, editorFontsStylesheetHref, } from "@stesura/fonts"; // inside <html>, before <body> — React hoists these into <head> {EDITOR_FONTS_PRECONNECT_ORIGINS.map((origin) => ( <link key={origin} rel="preconnect" href={origin} crossOrigin="anonymous" /> ))} <link id={EDITOR_FONTS_LINK_ID} rel="stylesheet" precedence="default" href={editorFontsStylesheetHref()} />

The fonts CSS now downloads in parallel with your JS, and the font files fetch over a warm connection the moment the editor mounts. Warm-cache visits are unaffected either way (the files are cached for a year).

Deliberately not provided: font-file (.woff2) preload URLs — Google’s CDN negotiates file URLs per user agent, so hardcoding them risks preloading files the stylesheet never references. If you need the last few hundred ms on cold loads, self-host your default document font (the Normal style’s family) under a stable same-origin URL and preload that.

A minimal editor

useLocalEditor owns an in-memory EditorState — no persistence, no backend. This runs as-is (live → ):

"use client"; import { EditorLoadingState } from "@stesura/editor-react"; import { useLocalEditor } from "@stesura/editor-react/hooks"; import { StesuraUserProvider } from "@stesura/editor-react/providers"; 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:

import { createDocContent } from "@stesura/core/helpers"; const doc = createDocContent(schema, [ { type: "heading", level: 1, text: "Minutes" }, { type: "paragraph", text: "Attendees: …" }, ]);

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"; // StesuraEditor is the dynamic import from the minimal example. import { useLocalEditor } from "@stesura/editor-react/hooks"; 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"; // 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/providers) 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