Migrate from v1

Migrate from v1

Move a browser editor from SuperDoc v1 to the v2 DOCX engine.

If your v1 application mounts SuperDoc from the package root, waits for readiness, and exports through the same instance, most of that integration remains valid. The migration is mainly about selecting the v2 package and removing dependencies on v1 internals.

Migrating with an AI coding agent?

Copy this prompt to have your agent inspect the project against the current migration documentation.

Help me migrate this project from SuperDoc v1 to v2.

First, read these sources of truth:
/md/editor/migrate-from-v1/overview.md
/migration/v1-to-v2.json

Inspect the project and report:
1. Removed imports and package subpaths
2. Any direct editor.* access, including commands, state, view, chain(), helpers, comments, presentationEditor, and on()
3. Legacy configuration and collaboration usage
4. Custom UI, extensions, and DOM selectors that require redesign
5. Synchronous Document API reads such as doc.extract(), doc.getMarkdown(), and doc.selection.current(), which the browser resolves as Promises

Do not change code yet. Classify each finding using the migration catalog, then propose the smallest safe migration sequence and a verification plan.

1. Install the v2 package

SuperDoc v2 is selected by the installed package version:

pnpm add superdoc@2

Keep the package name superdoc. Do not add editorVersion, v2, or v2Integration to the editor configuration. The v2 package always runs the v2 DOCX engine and has no runtime fallback to v1.

2. Keep the supported root integration

These browser integration points remain the same:

Integration pointV2 path
Editor classSuperDoc from superdoc
Stylessuperdoc/style.css
Document inputdocument with a File, Blob, or URL
Ready lifecycleonReady or the ready event
Mode changessetDocumentMode()
DOCX outputexport()
Cleanupdestroy()

A minimal v2 mount still looks familiar:

import { SuperDoc } from 'superdoc';
import 'superdoc/style.css';

const superdoc = new SuperDoc({
  selector: '#editor',
  document: file,
  onReady: ({ superdoc }) => {
    superdoc.setDocumentMode('editing');
  },
});

Call document-dependent methods only after onReady. V2 opens and renders the DOCX progressively, so readiness is the public boundary for starting product interaction.

3. Remove legacy package subpaths

SuperDoc v2 exposes a smaller public package surface:

V1 importV2 replacement
superdoc/typesNot a rename. See Removed in v2
superdoc/headless-toolbarsuperdoc.ui, the controller the instance owns
superdoc/headless-toolbar/reactHooks and providers from superdoc/ui/react
superdoc/headless-toolbar/vueBuild on superdoc.ui, the framework-agnostic controller
superdoc/super-editorUse the SuperDoc instance and its public active-editor facade
superdoc/converter, superdoc/docx-zipper, or superdoc/file-zipperUse the supported SuperDoc load and export workflow; there is no direct v2 subpath

Do not replace a removed public path with an internal package. Packages such as @superdoc/v2-host, @superdoc/headless, and @superdoc/document-api-v2-adapter are implementation details, not customer integration surfaces.

4. Replace direct editor internals

V1 code could reach through the editor to ProseMirror state and commands:

new SuperDoc({
  selector: '#editor',
  document: file,
  onEditorCreate: ({ editor }) => {
    const text = editor.state.doc.textContent;
    console.log(text);
  },
});

In v2, read and change document content through the public Document API after the editor is ready:

new SuperDoc({
  selector: '#editor',
  document: file,
  onReady: async ({ superdoc }) => {
    const doc = superdoc.activeEditor?.doc;
    if (!doc) throw new Error('The active document is unavailable.');

    const text = await doc.getText({});
    console.log(text);
  },
});

Do not use editor.state, editor.view, DOM text offsets, or private command objects as the document model. Use the Document API for fresh reads and mutations, and superdoc/ui for reactive custom UI state.

Move UI interactions off the document runtime

DocumentRendererRuntime is part of SuperDoc's rendering path. V1 integrations sometimes retrieved it with getDocumentRuntimeForDocument() and called navigation methods on it. That lookup is not the V2 readiness boundary. It can return null when the renderer is not available, which often surfaces as an application error such as DocumentRuntime is not available.

Read the instance-owned controller from superdoc.ui after onReady instead. The controller groups state and actions by the feature your interface is showing. It also owns the navigation needed to reveal content on a virtualized page.

Scroll to a tracked change

import type { SuperDoc } from 'superdoc';

export async function scrollToChange(superdoc: SuperDoc, changeId: string): Promise<boolean> {
  const result = await superdoc.ui.trackChanges.scrollTo(changeId);
  return result.success;
}
import type { SuperDoc } from 'superdoc';

export async function scrollToChange(
  superdoc: SuperDoc,
  documentId: string,
  changeId: string,
): Promise<boolean> {
  const runtime = superdoc.getDocumentRuntimeForDocument(documentId);
  if (!runtime?.scrollToElement) throw new Error('DocumentRuntime is not available.');
  return Boolean(await runtime.scrollToElement(changeId));
}

Use the narrowest navigation method for the target you already know:

Target you haveV2 navigation method
Tracked-change IDsuperdoc.ui.trackChanges.scrollTo(changeId)
Comment IDsuperdoc.ui.comments.scrollTo(commentId)
Content-control IDsuperdoc.ui.contentControls.scrollIntoView({ id })
Stable block, comment, or change IDsuperdoc.scrollToElement(elementId)

The feature-specific methods return structured results and keep feature state, such as the active tracked change, in the same controller. Use superdoc.scrollToElement() when you only have a stable extracted ID and do not know its entity type. It returns false when SuperDoc cannot route the navigation.

You do not need to import the normal controller from superdoc/ui. The superdoc.ui getter creates it once, and superdoc.destroy() owns its cleanup. Import createSuperDocUI from superdoc/ui only when you need a separately owned controller with its own lifecycle. Continue with Custom UI controller setup for subscriptions and cleanup, or the tracked-changes UI guide for review navigation.

5. Map common editor and ProseMirror internals

These are the mappings most applications need: reading text, resolving a selection, inserting at it, and exporting. Each example includes its imports, inputs, async boundary, and returned value. V2 is selected by default; switching an example switches every example on this page.

For everything else, work from Removed in v2. Each entry names the v1 symbol, what you will observe when it breaks, and the v2 replacement if one exists. Entries link to a canonical API page where one covers the replacement.

Document API

getText

import type { SuperDoc } from 'superdoc';

export async function readText(superdoc: SuperDoc): Promise<string> {
  const doc = superdoc.activeEditor?.doc;
  if (!doc) throw new Error('The active document is unavailable.');
  return doc.getText({});
}
import type { SuperDoc } from 'superdoc';

export function readText(superdoc: SuperDoc): string {
  return superdoc.activeEditor!.state.doc.textContent;
}

selection.current

import type { SuperDoc } from 'superdoc';

export async function currentSelection(superdoc: SuperDoc) {
  const doc = superdoc.activeEditor?.doc;
  if (!doc) throw new Error('The active document is unavailable.');
  return doc.selection.current({ includeText: true });
}
import type { SuperDoc } from 'superdoc';

export function currentSelection(superdoc: SuperDoc) {
  return superdoc.activeEditor!.state.selection;
}

selection.current().selectionTarget

import type { SuperDoc } from 'superdoc';

export async function selectionTarget(superdoc: SuperDoc) {
  const doc = superdoc.activeEditor?.doc;
  if (!doc) throw new Error('The active document is unavailable.');
  const selection = await doc.selection.current({});
  return selection.selectionTarget;
}
import type { SuperDoc } from 'superdoc';

export function selectionRange(superdoc: SuperDoc) {
  const { from, to } = superdoc.activeEditor!.state.selection;
  return { from, to };
}

insert

import type { SuperDoc } from 'superdoc';

export async function insertText(superdoc: SuperDoc, text: string) {
  const doc = superdoc.activeEditor?.doc;
  if (!doc) throw new Error('The active document is unavailable.');
  // A targetless insert appends at the end of the document. V1 inserted at
  // the caret, so a missing target is a lost selection, not a default.
  // Failing loudly beats silently putting the text somewhere else.
  const selection = await doc.selection.current({});
  if (!selection.selectionTarget) {
    throw new Error('No selection target. Capture one while the editor has focus, or pass an explicit target.');
  }
  const result = (await doc.insert({ value: text, target: selection.selectionTarget })) as
    | { success: boolean }
    | { receipt: { success: boolean } };
  const receipt = 'receipt' in result ? result.receipt : result;
  if (!receipt.success) throw new Error('Text insertion failed.');
  return result;
}
import type { SuperDoc } from 'superdoc';

export function insertText(superdoc: SuperDoc, text: string) {
  const editor = superdoc.activeEditor!;
  return editor.view.dispatch(editor.state.tr.insertText(text));
}

Pass a target unless you mean to append. doc.insert({ value }) with no target or ref routes to the end of the document, so a direct port of tr.insertText(text) appends instead of inserting at the caret. In a browser this example throws rather than falling back, because losing the selection means the text would land somewhere the user did not choose. Headless callers with no selection can omit the target deliberately and take the append.

The Editor returns a mutation receipt directly, while the headless SDK returns an operation envelope containing the receipt. Normalize the two shapes before checking success, as shown above. Headless callers can also use the envelope's target, resolvedRange, and context for details about the applied mutation. Verify persisted output by saving and reopening the DOCX when insertion is part of an automated workflow.

Export

getMarkdown

import type { SuperDoc } from 'superdoc';

export async function markdown(superdoc: SuperDoc): Promise<string> {
  const doc = superdoc.activeEditor?.doc;
  if (!doc) throw new Error('The active document is unavailable.');
  return doc.getMarkdown({});
}
import type { SuperDoc } from 'superdoc';

export function markdown(superdoc: SuperDoc): string {
  return superdoc.activeEditor!.getMarkdown();
}

export

import type { SuperDoc } from 'superdoc';

export async function exportDocx(superdoc: SuperDoc) {
  return superdoc.export({ exportType: ['docx'], triggerDownload: false });
}
import type { SuperDoc } from 'superdoc';

export async function exportDocx(superdoc: SuperDoc) {
  return superdoc.export({ exportType: ['docx'], triggerDownload: false });
}

6. Migrate changed integration contracts

The following changes are public V2 contracts rather than replacements for ProseMirror internals. Treat the browser Document API as asynchronous, keep document and UI namespaces explicit, and preserve every returned cleanup function.

Collaboration

document.v2Collaboration

import type { Document, V2CollaborationConfig } from 'superdoc';

export function collaborativeDocument(
  data: Blob,
  id: string,
  serverUrl: string,
  token: string,
  roomMode: 'join' | 'create' = 'join',
): Document {
  const v2Collaboration: V2CollaborationConfig = {
    providerType: 'hocuspocus', documentId: id, serverUrl, token, roomMode,
  };
  return {
    id, type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', data, v2Collaboration,
  };
}
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';

const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({ url: serverUrl, name: id, document: ydoc });
const modules = { collaboration: { ydoc, provider } };

V2 owns the provider and Y.Doc; do not pass an external pair through modules.collaboration. Use roomMode: 'join' for an existing room and 'create' only while seeding a missing room. Wait for onCollaborationReady before reading instance.provider, handle collaboration-v2-room-missing and collaboration-v2-room-already-exists in onException, and let destroy() release the owned collaboration lifecycle. V2 has no join-or-create mode: retrying with a different room mode requires a fresh mount.

7. Verify one DOCX round trip

Before migrating advanced UI or automation, verify the smallest complete path:

  1. Open a representative DOCX in v2.
  2. Wait for onReady.
  3. Make one direct or tracked edit.
  4. Export the document with SuperDoc.export().
  5. Reopen the result in Microsoft Word or another DOCX reader.
  6. Confirm the edited content, formatting, comments, and tracked changes still match the intended document state.

Keep v1 and v2 on separate branches or deployments while comparing the same input documents. A package upgrade should remain easy to reverse until the documents that matter to your product complete this round trip.

Once that round trip holds, migrate whatever else your application uses, working from Removed in v2 and the canonical API pages it links to.

Next, follow the Editor quickstart for a complete v2 mount, or learn the Document API mental model before migrating programmatic edits.

On this page