Scrivr
Guides

Serialization

Reading and writing document content — JSON, Markdown, and programmatic content loading.

Scrivr stores the document as an immutable ProseMirror EditorState. This guide covers how to serialize it for storage and how to load content back into the editor.


Reading Content

JSON

The most reliable format — lossless round-trip with full schema fidelity:

const state = editor.getState();
const json = state.doc.toJSON();
// Save to your backend / localStorage:
localStorage.setItem('doc', JSON.stringify(json));

JSON output mirrors the ProseMirror node structure:

{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 1, "align": "left" },
      "content": [{ "type": "text", "text": "Hello" }]
    },
    {
      "type": "paragraph",
      "content": [{ "type": "text", "text": "World" }]
    }
  ]
}

Markdown

const markdown = editor.getMarkdown();
// "# Hello\n\nWorld"

getMarkdown() uses extension-contributed serializer rules. Marks and nodes without rules (e.g. image) are serialized as their closest Markdown equivalent or omitted.


Loading Content

Initial content is provided via addInitialDoc() in an extension. The hook runs synchronously at editor construction and returns a ProseMirror Node (or null to start empty).

The recommended pattern is to accept the document as a prop — your component stays clean and your application layer decides how to fetch it (REST, GraphQL, localStorage, etc.).

import { useState } from 'react';
import { useScrivrEditor, Scrivr, StarterKit } from '@scrivr/react';
import { Extension } from '@scrivr/core';

interface EditorProps {
  /** ProseMirror JSON document to open. Pass null to start empty. */
  initialDoc: Record<string, unknown> | null;
  onUpdate?: (json: Record<string, unknown>) => void;
}

export function MyEditor({ initialDoc, onUpdate }: EditorProps) {
  // useState initializer runs once — captures initialDoc at mount time.
  const [extensions] = useState(() => [
    StarterKit,
    Extension.create({
      name: 'initialContent',
      addInitialDoc() {
        return initialDoc ? this.schema.nodeFromJSON(initialDoc) : null;
      },
    }),
  ]);

  const editor = useScrivrEditor({
    extensions,
    onUpdate: ({ editor }) => {
      onUpdate?.(editor.getState().doc.toJSON());
    },
  });

  return <Scrivr editor={editor} style={{ height: '100vh' }} />;
}

Your application layer handles the fetch and passes the result down:

// Next.js server component
const doc = await db.documents.findOne(id);
return <MyEditor initialDoc={doc.content} onUpdate={(json) => save(id, json)} />;

// React Query
const { data } = useQuery({ queryKey: ['doc', id], queryFn: () => fetchDoc(id) });
if (!data) return <Skeleton />;
return <MyEditor initialDoc={data.content} />;

// localStorage
const saved = JSON.parse(localStorage.getItem('doc') ?? 'null');
return <MyEditor initialDoc={saved} onUpdate={(json) => localStorage.setItem('doc', JSON.stringify(json))} />;

The useState initializer closes over initialDoc once at mount time, so the editor constructs with the correct document even if the parent re-renders before the editor mounts. Changing initialDoc after mount has no effect — create a new editor instance (via the deps array) if you need to replace the document entirely.


Auto-Save on Change

Use onUpdate (React) or onChange (headless) to persist on every edit. Always debounce to avoid excessive writes:

// React
const editor = useScrivrEditor({
  extensions: [StarterKit],
  onUpdate: ({ editor }) => {
    const json = editor.getState().doc.toJSON();
    debouncedSave(json);
  },
});

// Headless
const editor = new Editor({
  extensions: [StarterKit],
  onChange: (state) => {
    debouncedSave(state.doc.toJSON());
  },
});

Clearing the Document

Replace the content with a minimal valid document — a single empty paragraph:

import { Extension } from '@scrivr/core';

const EmptyDoc = Extension.create({
  name: 'emptyDoc',
  addInitialDoc() {
    return this.schema.nodeFromJSON({
      type: 'doc',
      content: [{ type: 'paragraph' }],
    });
  },
});

Or create a fresh editor instance with no initial content extension — the editor defaults to an empty paragraph automatically.


Server-side Serialization

Use ServerEditor from @scrivr/core to parse and transform documents in Node.js without mounting a canvas:

import { ServerEditor, StarterKit } from '@scrivr/core';

const server = new ServerEditor({ extensions: [StarterKit] });
server.setContent(saved.content); // load from ProseMirror JSON

// Read
const markdown = server.getMarkdown();
const json = server.getState().doc.toJSON();

// Apply a transaction programmatically
const state = server.getState();
server.applyTransaction(state.tr.insertText('Prepended text.\n\n', 0));

// Save
await db.saveDocument(id, server.getState().doc.toJSON());

ServerEditor accepts the same extensions and pageConfig as the browser Editor but never renders a canvas. It is safe to use in Next.js Route Handlers, Hono endpoints, or any Node.js environment.


Export to other formats

For PDF and Markdown file downloads, see the Document Export guide and the full @scrivr/export package reference.

On this page