Server
On this page
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.
| Feature | What your server provides | Required? |
|---|---|---|
| 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 |
| 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:
GET /api/users?ids=u-1,u-2,u-3[
{ "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
maxBatchids 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.
avatarmust behttps://or a base64data:image/…URL. Anything else is ignored.
Search
Used for @ mentions in documents and comment replies.
GET /api/users/search?q=sam[{ "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)
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.
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.
| From | Type | Purpose |
|---|---|---|
| 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:
{
"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' }) }).
| Method | Path | Body | Response |
|---|---|---|---|
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) |
{
"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:
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:
{ "blocks": [{ "id": "n12", "text": "Teh cat sat.", "language": "en-GB" }] }{ "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.
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:
{ "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.
| Method | Path | Body | Response |
|---|---|---|---|
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:
{
"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:
{ "id": "company", "label": "Company", "readOnly": true }GET /templatesreturns templates in display order, withoutcontent(send it if it is small; the editor then skipsGET /templates/:id).searchmatches name, description and tags;categoryfilters by id. Both are optional.contentis HTML. Leave{{variables}}in it: the editor fills them in when someone inserts the template.versionis a string that changes on every update. The editor sends the version it loaded withPATCH; return 409 when it no longer matches, so one person's edit cannot silently overwrite another's.moveplaces the template beforebeforein the target category, or last whenbeforeis null.category: nullmeans no category.POST /templatesalso 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:
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.
| Method | Path | Body | Response |
|---|---|---|---|
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 |
{
"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"
}{ "id": "f-logos", "name": "Logos", "parent": "f-brand" }Listing returns one page of the folder (
folderabsent means the root). Send sub-folders infolderson the first page only, and the folder's ancestors from the root down (including itself) inpathfor the breadcrumbs.nextCursoris opaque; returnnullon the last page.kindisimageorfile; without it,image/*MIME types count as images.id,nameandurlare required. Items missing them are dropped, so a bad row never reaches the editor.searchis free text. Whether it searches the current folder or everything is up to you; the in-memory source searches everything.urlis what goes into the document. Images may use http(s) ordata:image/…URLs. Attachments need http(s).altis 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
headersyou configure and same-origin cookies (credentialschanges that). Uploads report byte progress throughXMLHttpRequest.
Example (Express)
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
parseHtmlordecodeDocument, which drop anything the schema does not allow.