← All articles

June 1, 2026 · Umar · 4 min read

Porting Tiptap to Flutter without rewriting Tiptap

If your web app uses Tiptap, your content isn't really "rich text" only — it's a ProseMirror JSON tree whose shape is defined by the exact extensions you've loaded. So "support Tiptap on Flutter" actually means "reproduce ProseMirror's document model, schema, transactions, and every extension — and keep it in sync with the web forever." A from-scratch Dart editor can only approximate that, and the approximation loses fidelity on the documents you care about most.

So I didn't reproduce the behavior. I ran the original code.

The decision

There were three options. Port ProseMirror to Dart — years of work, and you own a moving target forever. Render Tiptap in a visible WebView — engine, but keyboard, scroll, and focus fight the platform and it never feels native. Or: run the engine and render nothing with it.

I took the third. Load unmodified Tiptap JS into a WebView that's never shown, use it purely as a document computation engine, and let Flutter paint every pixel and handle every keystroke. The engine is authoritative about the document; Flutter is authoritative about the UI experience. That's the architecture.

Engine

tiptap-engine is a standalone JS bundle — no Flutter assumptions, so it could drive a SwiftUI or Compose port unchanged. It gives ProseMirror the real DOM it demands (WKWebView on iOS, Chromium WebView on Android) but keeps it off-screen.

The ownership line is strict. The engine owns the document model, schema, transactions, the command system, and serialization. It owns nothing visual — no rendering, input, clipboard, or cursors. The moment it owns one pixel, the visible-WebView problems come back.

Since the two sides only speak JSON, the protocol is the real product. Three decisions did the heavy lifting:

  1. Commands / responses / events. The port sends commands (each with an id); the engine replies by id and pushes stateChanged events on every transaction.
  2. Position-annotated nodes. Every node carries its ProseMirror pos/end. On a tap, the port hit-tests its own widgets, adds the local offset to the node's pos, and sends one exact setTextSelection — it never has to understand ProseMirror's position math.
  3. Differentiated state events. The engine doesn't push one fat event for everything. It splits state pushes by what changed:
    1. stateChanged — the full payload (doc tree plus a canExec/isActive command-state map), fired on every transaction.
    2. selectionChanged — the command states and selection without the doc tree, for selection-only moves.
    3. contentChanged — just the doc, for debounced autosave.

The hard target was a full round-trip — keystroke to rebuilt widget — inside one 16ms frame. It lands under 6ms.

UI

tiptap_flutter copies Tiptap's own philosophy: headless core, composable UI. The EditorController owns the engine lifecycle and exposes state streams — and it's the only thing that touches the bridge. Widgets stay small and optional: TiptapEditor (the one required widget) walks the annotated tree into native widgets and handles gestures, selection, and input; TiptapToolbar just listens to the controller and rebuilds. Don't like them? Build your own UI straight off the controller's streams. Custom node types and image sourcing are both injectable.

The payoff: a document written on web opens on Flutter and reads identically, because the same engine parsed it. No translation layer, because there's no translation.

Both repos are MIT, and the demo, full API, and current limitations live in the READMEs — worth a look rather than me restating them here.