Better Rich Text Buy licence

Server

On this page
  1. Users
  2. Look up
  3. Search
  4. Example (Express)
  5. Collaboration
  6. Messages
  7. Comments
  8. Suggested edits
  9. Revision history
  10. Uploads
  11. Proofing
  12. AI
  13. PDF
  14. Templates
  15. Assets
  16. Example (Express)
  17. Security checklist

Better Rich Text runs entirely in the browser. You only need a server for features that store or fetch shared data. This page lists every endpoint the editor can call, with the request and response shapes. All of them are optional, and each is configured with your own URL or function, so the editor never talks to a service you did not choose.

FeatureWhat your server providesRequired?
Users Look up people by id; search them Only for names and avatars in collaborative features
Templates A REST store for the template library, or none (templates in the page) No
Assets A file store to browse, upload, rename and delete files Only for the file manager
Collaboration A WebSocket authority (we ship it as a Node package) Only for real-time co-editing
Comments Nothing (stored in the document), or a REST store No
Suggested edits Nothing (stored in the document) No
Revision history A REST store, or none (browser storage) No
Uploads An upload function Only to store images and files outside the document
Proofing A spelling and grammar endpoint No (browser spellcheck works without one)
AI A streaming model endpoint Only for the AI assistant
PDF A headless renderer No (PDFs are written in the browser without one)

Users

Configured with createUsersPlugin({ currentUserId, fetchUsers, searchUsers }). See Users.

Look up

fetchUsers(ids, signal) is your function, so any transport works. A typical REST shape:

HTTP
GET /api/users?ids=u-1,u-2,u-3
JSON
[
  { "id": "u-1", "name": "Maya Chen", "avatar": "https://cdn.example.com/a/u-1.png", "description": "Managing editor" },
  { "id": "u-2", "name": "Sam Ortiz", "description": "Design", "color": "#0f766e" }
]
  • At most maxBatch ids per call (default 100).

  • Return only users you know, in any order. Missing ids show as "Unknown user".

  • Only return people the current user is allowed to see. The editor shows whatever you return.

  • avatar must be https:// or a base64 data:image/… URL. Anything else is ignored.

Used for @ mentions in documents and comment replies.

HTTP
GET /api/users/search?q=sam
JSON
[{ "id": "u-2", "name": "Sam Ortiz", "description": "Design" }]

Return up to about 10 matches, best first. The editor debounces typing and aborts stale requests through signal.

Example (Express)

JavaScript
app.get('/api/users', requireSession, async (req, res) => {
  const ids = String(req.query.ids ?? '').split(',').filter(Boolean).slice(0, 100);
  const users = await db.users.findMany({ where: { id: { in: ids }, orgId: req.session.orgId } });
  res.json(users.map((u) => ({ id: u.id, name: u.displayName, avatar: u.avatarUrl, description: u.title })));
});

Collaboration

@better-rich-text/collab-server is a Node WebSocket server that orders and validates every change. You supply authentication and storage. Design notes are in ADR 0004.

JavaScript
import { collabPlugin, createCollabServer, FileStorage } from '@better-rich-text/collab-server';
import { standardPreset } from '@better-rich-text/core';
import { commentsPlugin } from '@better-rich-text/plugin-comments';

await createCollabServer({
  port: 1234,
  allowedOrigins: ['https://app.example.com'],
  // The same plugins, in the same order, as the clients (the schema hash must match).
  plugins: [...standardPreset.plugins, commentsPlugin, collabPlugin],
  authenticate: async (token, documentId) => {
    const session = await sessions.verify(token);
    if (!session || !(await canOpen(session.userId, documentId))) return null;
    return {
      actorId: session.userId,                 // the same id fetchUsers resolves
      name: session.displayName,
      permissions: ['view', 'comment', 'edit'], // 'view' | 'comment' | 'edit'
      color: '#c2410c',                         // optional
    };
  },
  // CollabStorage: appendEntry, readEntries, loadSnapshot, saveSnapshot, compact.
  // MemoryStorage and FileStorage ship with the package; back it with your database in production.
  storage: new FileStorage({ directory: '.collab-data' }),
});

The identity returned by authenticate is the only one the server trusts. Clients cannot choose their own actorId, so authors of comments, suggestions and edits cannot be forged.

Messages

Clients and the server exchange JSON messages over the socket.

FromTypePurpose
Client hello { v, documentId, schemaHash, token, lastRevision, sessionId } opens the session
Client submit { commands: CommandEnvelope[] } sends edits as commands, not DOM diffs
Client sync { fromRevision } catches up after a gap
Client presence { presence: { selection, focused } } reports the caret and focus
Client ping { t } keepalive
Server welcome { actor, actorTag, revision, snapshot, entries, peers, readOnly, … }
Server entries { entries: LogEntry[] } reports changes committed by anyone
Server ack / reject { commandId, revision } / { commandId, error, revision }
Server presence / leave other people's carets
Server error { error, fatal, retryAfterMs? }

A command envelope looks like this:

JSON
{
  "id": "a7.s3.42",
  "command": "text.insert",
  "input": { "text": "Hello" },
  "selection": { "kind": "text", "anchor": { "nodeId": "n12", "offset": 5 }, "focus": { "nodeId": "n12", "offset": 5 } },
  "storedMarks": null,
  "baseRevision": 118
}

Comments and suggested edits travel through this same log, so they need no extra server code.

Comments

By default comment threads are stored in the document, next to the text they belong to. They save with the document and sync through collaboration, so no server work is needed.

To keep threads in your own backend instead (for notifications, search or moderation), implement a CommentsStore. The exact thread, message and reaction shapes and the REST example are in Comments.

Notifications for @ mentions: listen for the comments.mentioned action and call your own notification service. The editor never sends emails or messages itself.

Suggested edits

Suggestions are stored in the document as marked insertions, deletions and format changes, with the author's user id and time. They sync through collaboration and save with your content, so no server code is needed.

To accept or reject suggestions on the server (when publishing, say), use the headless helpers on the stored JSON. No browser is needed. The JSON shape and a Node example are in Suggested edits.

Revision history

Without a store, versions are kept in the browser (IndexedDB). For shared history, point the plugin at your REST API: createRevisionsPlugin({ store: httpRevisionStore({ baseUrl: '/api' }) }).

MethodPathBodyResponse
GET /documents/:documentId/revisions?limit=50&cursor=… { "items": RevisionSummary[], "nextCursor": string | null }, newest first
GET /documents/:documentId/revisions/:id Revision, or 404
POST /documents/:documentId/revisions Revision (header Idempotency-Key: <id>) RevisionSummary
PATCH /documents/:documentId/revisions/:id { "label": string | null } RevisionSummary
DELETE /documents/:documentId/revisions/:id 204 (404 is fine)
JSON
{
  "id": "rlx2k9q1-7f3k2m9a",
  "documentId": "doc-42",
  "createdAt": "2026-10-30T09:12:44.120Z",
  "author": { "id": "u-1", "name": "Maya Chen" },
  "label": "Sent to legal",
  "kind": "manual",
  "parentId": "rlx2j0a4-2b8c1d0e",
  "contentRevision": 118,
  "summary": { "added": 42, "removed": 7, "changedBlocks": 3 },
  "document": { "format": "better-rich-text", "...": "the full document envelope (GET by id and POST only)" }
}

Revisions never change after they are created; only label can be edited. A POST repeated with the same id and body must succeed, and a different body under an existing id must return 409. Lists use opaque cursors. More in Revision history.

Uploads

Images and attachments go through a function you supply, so you can use signed URLs, S3, Cloudinary or your own API:

TypeScript
createImagePlugin({
  provider: {
    upload: async (file, { signal, onProgress }) => {
      const { url, uploadUrl } = await fetch('/api/uploads', { method: 'POST', signal, body: JSON.stringify({ name: file.name, type: file.type, size: file.size }) }).then((r) => r.json());
      await fetch(uploadUrl, { method: 'PUT', body: file, signal });
      onProgress(1);
      return { src: url };
    },
  },
});

Without a provider, small images are embedded as data URLs.

Proofing

httpProvider({ endpoint: '/api/proof' }) sends text blocks and expects issues back:

JSON
{ "blocks": [{ "id": "n12", "text": "Teh cat sat.", "language": "en-GB" }] }
JSON
{ "issues": [{ "blockId": "n12", "start": 0, "end": 3, "kind": "spelling", "message": "Possible spelling mistake", "replacements": ["The"] }] }

Other services (LanguageTool, AI models) are covered in Proofing.

AI

The assistant calls your endpoint, which calls the model with your key. Keys never reach the browser. The request and streaming response shapes and an Anthropic example are in AI.

PDF

Export PDF writes the file in the browser and needs no server. For full CSS fidelity, or for scripts that need shaping such as Arabic and Devanagari, pass httpPdfRenderer('/api/pdf'). It posts:

JSON
{ "html": "<!doctype html>…", "title": "Q3 report", "filename": "q3-report.pdf", "settings": { "size": "A4", "orientation": "portrait", "margins": { "top": 20, "right": 18, "bottom": 20, "left": 18 }, "footer": "{title}", "pageNumbers": true }, "documentId": "doc-42", "revision": 118 }

and expects application/pdf back. Render html with a headless browser such as Playwright or Puppeteer, with JavaScript disabled. The HTML is self-contained and has no scripts.

Templates

Without a server, pass templates to the plugin (templates: [...]) and they are kept in memory. For a shared library, point the plugin at your REST API: createTemplatesPlugin({ source: httpTemplateSource({ baseUrl: '/api' }) }). Leave out the write endpoints (and pass readOnly: true) for an insert-only library. More in Smart content: Templates.

MethodPathBodyResponse
GET /templates?search=…&category=… { "templates": TemplateSummary[], "categories": TemplateCategory[] }
GET /templates/:id Template, or 404
POST /templates { "name", "content", "category"?, "description"?, "tags"? } Template or TemplateSummary (201)
PATCH /templates/:id Any of { "name", "content", "category" (null removes it), "description", "tags" }, plus "version" Template or TemplateSummary
DELETE /templates/:id 204
POST /templates/:id/move { "category": string | null, "before": string | null } TemplateSummary, or 204
POST /template-categories { "label" } TemplateCategory (201)
PATCH /template-categories/:id { "label" } TemplateCategory
DELETE /template-categories/:id 204; the category's templates are deleted too

A template:

JSON
{
  "id": "t-7",
  "name": "Follow-up after demo",
  "description": "Next steps after a product demo",
  "category": "sales",
  "tags": ["sales", "email"],
  "version": "3",
  "readOnly": false,
  "content": "<p>Hi {{customer}},</p><p>Thanks for your time on {{date}}.</p><p>{{user.name}}</p>"
}

A category:

JSON
{ "id": "company", "label": "Company", "readOnly": true }
  • GET /templates returns templates in display order, without content (send it if it is small; the editor then skips GET /templates/:id). search matches name, description and tags; category filters by id. Both are optional.

  • content is HTML. Leave {{variables}} in it: the editor fills them in when someone inserts the template.

  • version is a string that changes on every update. The editor sends the version it loaded with PATCH; return 409 when it no longer matches, so one person's edit cannot silently overwrite another's.

  • move places the template before before in the target category, or last when before is null. category: null means no category.

  • POST /templates also serves "Save selection as snippet": the body is { "name", "content", "description", "category"? }.

  • Errors: 403 for read-only templates and categories and anything the user may not change, 404 for unknown ids, 409 for a stale version, 400 or 422 for invalid input. A JSON body { "message": "…" } is shown to the user.

Read-only (admin) categories and templates must be enforced here. The editor hides the actions and its commands refuse them, but a client can always send a request:

JavaScript
app.patch('/api/templates/:id', requireSession, async (req, res) => {
  const template = await db.templates.find(req.params.id, { orgId: req.session.orgId });
  if (!template) return res.status(404).end();
  const category = template.categoryId && (await db.categories.find(template.categoryId));
  if (template.readOnly || category?.readOnly || !canEditTemplates(req.session)) return res.status(403).json({ message: 'This template is read-only' });
  if (req.body.version !== undefined && req.body.version !== String(template.version)) return res.status(409).json({ message: `${template.name} was changed by someone else` });
  const updated = await db.templates.update(template.id, pick(req.body, ['name', 'content', 'category', 'description', 'tags']), { bumpVersion: true });
  res.json({ ...updated, version: String(updated.version) });
});

Store content as sent and treat it as untrusted. The editor parses it through its schema on insertion and in the preview, so unsupported or unsafe markup is dropped there; if you render templates anywhere else, sanitise them yourself.

Assets

The file manager (@better-rich-text/plugin-assets) reads and writes files through an AssetSource you pass to createAssetsPlugin({ source }). httpAssetSource({ baseUrl: '/api' }) speaks the REST shape below. Only listing is required: leave out an endpoint and pass it in disable (['upload', 'delete', 'rename', 'createFolder']), and the manager hides that control. More in Smart content.

MethodPathBodyResponse
GET /assets?folder=…&search=…&kind=image|file&cursor=… { "assets": Asset[], "folders": AssetFolder[], "path": AssetFolder[], "nextCursor": string | null }
POST /assets multipart/form-data: file, name, folder (omitted for the root) Asset
PATCH /assets/:id { "name": string } Asset
DELETE /assets/:id 204 (404 is fine)
POST /asset-folders { "name": string, "parent": string | null } AssetFolder
JSON
{
  "id": "a-7f3k2m9a",
  "name": "harbour-dusk.jpg",
  "url": "https://cdn.example.com/a/7f3k2m9a.jpg",
  "kind": "image",
  "mimeType": "image/jpeg",
  "size": 482000,
  "width": 2400,
  "height": 1600,
  "thumbnailUrl": "https://cdn.example.com/a/7f3k2m9a-320.jpg",
  "folder": "f-brand",
  "updatedAt": "2026-10-28T10:00:00.000Z",
  "alt": "Boats in the harbour at dusk"
}
JSON
{ "id": "f-logos", "name": "Logos", "parent": "f-brand" }
  • Listing returns one page of the folder (folder absent means the root). Send sub-folders in folders on the first page only, and the folder's ancestors from the root down (including itself) in path for the breadcrumbs. nextCursor is opaque; return null on the last page.

  • kind is image or file; without it, image/* MIME types count as images. id, name and url are required. Items missing them are dropped, so a bad row never reaches the editor.

  • search is free text. Whether it searches the current folder or everything is up to you; the in-memory source searches everything.

  • url is what goes into the document. Images may use http(s) or data:image/… URLs. Attachments need http(s).

  • alt is the stored description and becomes the image's alternative text when it is inserted.

  • Errors: any non-2xx status. Put a human-readable reason in { "error": { "message": "…" } } or { "message": "…" }, and the manager shows it.

  • Requests carry the headers you configure and same-origin cookies (credentials changes that). Uploads report byte progress through XMLHttpRequest.

Example (Express)

JavaScript
app.get('/api/assets', requireSession, async (req, res) => {
  const { folder = null, search = '', kind, cursor } = req.query;
  const page = await db.assets.page({ orgId: req.session.orgId, folder, search, kind, cursor, limit: 40 });
  res.json({
    assets: page.items.map(toAsset),
    folders: cursor ? [] : await db.folders.children(req.session.orgId, folder),
    path: await db.folders.ancestors(req.session.orgId, folder),
    nextCursor: page.nextCursor ?? null,
  });
});

app.post('/api/assets', requireSession, upload.single('file'), async (req, res) => {
  const stored = await storage.put(req.file, { orgId: req.session.orgId }); // check type and size first
  res.status(201).json(toAsset(await db.assets.create({ ...stored, name: req.body.name, folder: req.body.folder ?? null })));
});

Check uploads on the server: sniff the real file type rather than trusting the name or Content-Type, cap the size, scope every query to what the user may see, and serve user files from a separate domain. Do not serve uploaded SVG or HTML inline from your app's origin.

Security checklist

  • Authenticate every endpoint with the user's session. The editor sends cookies or the headers you configure; it never invents credentials.

  • Scope lookups and searches to what the user may see.

  • Treat everything from the client as untrusted. Re-validate documents on save, for example by parsing them with the same plugins via parseHtml or decodeDocument, which drop anything the schema does not allow.