ProseMirror — and TipTap, which wraps it — gives you fine-grained control over a document, which is exactly what a grammar checker needs. Underlines must anchor to specific ranges, suggestions must track the text as the user keeps typing, and accepting a correction must be an ordinary, undoable edit.
This guide builds that integration piece by piece: a debounced proofreading loop, a plugin that renders corrections as decorations, and accept/dismiss interactions. The proofreading itself comes from AmberPen's API, which returns structured edits — exact ranges and replacements — rather than a rewritten blob of text.
The full source-style walkthrough below uses ProseMirror primitives; the TipTap section at the end shows how the same plugin drops into a TipTap editor unchanged.
Architecture in one paragraph
The browser never talks to the proofreading API directly. Your editor sends text to an endpoint on your server; the server calls the API with a key only it holds and returns the edits. That keeps credentials out of the client bundle and gives you a place to enforce your own rate limits and logging.
Know the two coordinate systems
This is the one genuinely tricky part of the integration, so get it right first.
Every edit the API returns is a TextEdit, exported from the SDK so you never redeclare it:
interface TextEdit {
/** A request-local identifier, unique within the returned edit list. */
readonly id: number;
/** The inclusive UTF-16 offset at which the replacement starts. */
readonly start: number;
/** The exclusive UTF-16 offset at which the replacement ends. */
readonly end: number;
/** The exact source substring between `start` and `end`. */
readonly original: string;
/** The text to insert in place of `original`. */
readonly replacement: string;
}Those offsets are UTF-16 code units into the document text — the same indexing JavaScript strings use, so text.slice(edit.start, edit.end) returns edit.original exactly. ProseMirror uses positions: an integer cursor into the document tree where entering a paragraph node costs one position, text characters cost one each, and so on. Offset 0 in a single-paragraph document is position 1.
For documents with more than one block, walk the text nodes and count characters:
import type { Node as ProseMirrorNode } from "prosemirror-model";
function offsetToPos(doc: ProseMirrorNode, offset: number): number {
let remaining = offset;
let result = 0;
doc.descendants((node, pos) => {
if (!node.isText) return true; // keep descending
const length = node.text!.length;
if (remaining <= length) {
result = pos + remaining;
remaining = Number.POSITIVE_INFINITY; // done
return false;
}
remaining -= length;
return false; // text nodes have no children
});
return result;
}This treats the document as the concatenation of its text nodes — the same string doc.textContent produces — so the offsets you validated against the text stay consistent with the positions you render. The repo for AmberPen's own live demo uses this pattern in production.
Render corrections as decorations
Decorations are ProseMirror's mechanism for marking ranges without changing the document. A plugin holds a DecorationSet built from the current edits:
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import type { Node as ProseMirrorNode } from "prosemirror-model";
import type { TextEdit } from "@amber-pen/sdk";
const proofreadKey = new PluginKey<DecorationSet>("proofread");
function decorationsFromEdits(
doc: ProseMirrorNode,
edits: readonly TextEdit[],
): DecorationSet {
const text = doc.textContent;
const decorations: Decoration[] = [];
for (const edit of edits) {
// Never decorate a range that no longer matches — the edit is stale.
if (
edit.start < 0 ||
edit.end < edit.start ||
edit.end > text.length ||
text.slice(edit.start, edit.end) !== edit.original
) {
continue;
}
const from = offsetToPos(doc, edit.start);
const to = offsetToPos(doc, edit.end);
if (from === to) {
// A pure insertion: there is no range to underline, so place a marker.
decorations.push(
Decoration.widget(from, () => {
const marker = document.createElement("span");
marker.className = "issue insertion";
marker.dataset.editId = String(edit.id);
return marker;
}, { key: `edit-${edit.id}`, side: -1 }),
);
} else {
decorations.push(
Decoration.inline(from, to, {
class: "issue",
"data-edit-id": String(edit.id),
}),
);
}
}
return DecorationSet.create(doc, decorations);
}Two details matter here. First, the guard that compares slice(start, end) against the edit's original text: if the document drifted, you skip the decoration instead of underlining the wrong words. Second, pure insertions (start === end) have no range to underline, so they get a widget marker instead.
The plugin itself maps decorations through every transaction, so underlines follow the text as the user types:
export function proofreadPlugin() {
return new Plugin<DecorationSet>({
key: proofreadKey,
state: {
init: () => DecorationSet.empty,
apply(transaction, current) {
const edits = transaction.getMeta(proofreadKey) as
| readonly TextEdit[]
| undefined;
if (edits) return decorationsFromEdits(transaction.doc, edits);
if (transaction.docChanged) {
return current.map(transaction.mapping, transaction.doc);
}
return current;
},
},
props: {
decorations(state) {
return proofreadKey.getState(state);
},
},
});
}Style the marks with the squiggly underline users expect:
.issue {
text-decoration: underline wavy var(--issue-color, #d97706);
text-decoration-skip-ink: none;
text-underline-offset: 3px;
cursor: pointer;
}
.issue.insertion {
display: inline-block;
width: 2px;
height: 1em;
background: var(--issue-color, #d97706);
vertical-align: text-bottom;
}Debounce, version, discard
Typing generates a stream of documents; you only want to proofread the one the user paused on. Send a request after a short idle delay, tag it with a version, and throw away any response that arrives late:
import type { EditorView } from "prosemirror-view";
let timer: ReturnType<typeof setTimeout> | undefined;
let version = 0;
let inFlight: AbortController | undefined;
function scheduleProofread(view: EditorView) {
const current = ++version;
clearTimeout(timer);
timer = setTimeout(async () => {
const text = view.state.doc.textContent;
inFlight?.abort();
inFlight = new AbortController();
try {
const response = await fetch("/api/proofread", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
signal: inFlight.signal,
});
const { edits } = await response.json();
// The user typed (or a newer request finished) while this one ran.
if (current !== version) return;
if (view.state.doc.textContent !== text) return;
view.dispatch(view.state.tr.setMeta(proofreadKey, edits));
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
throw error;
}
}, 400);
}Call scheduleProofread from the plugin view's update method whenever state.docChanged. The grammar checker architecture guide covers this loop — debouncing, versioning, and cancellation — in more detail.
On your server, the endpoint is a thin wrapper around the SDK:
import { createAmberPenClient } from "@amber-pen/sdk";
const amberPen = createAmberPenClient({ apiKey: process.env.AMBER_PEN_API_KEY });
export async function POST(request: Request) {
const { text } = await request.json();
const result = await amberPen.proofread({ text });
return Response.json({ edits: result.edits });
}Pass your product's vocabulary with every request — dictionary for accepted terms, properNouns for names — so the checker never flags your own features. The custom dictionaries guide shows the shape.
Accept a suggestion
Clicking a decoration opens your suggestion UI — a popover anchored to the data-edit-id element works well. Accepting is a normal, undoable transaction:
function acceptEdit(view: EditorView, edit: TextEdit) {
const from = offsetToPos(view.state.doc, edit.start);
const to = offsetToPos(view.state.doc, edit.end);
view.dispatch(view.state.tr.insertText(edit.replacement, from, to));
}Because the replacement goes through a regular transaction, ProseMirror's history plugin gives users undo for free. After applying, remove the accepted edit from your suggestion set and re-dispatch the remaining ones — or simply let the next scheduled proofread refresh everything.
Dismissing is even simpler: drop the edit from state and re-dispatch. Track dismissed edit ranges if you want to avoid re-flagging the same text until it changes.
Drop it into TipTap
TipTap compiles down to ProseMirror plugins, so everything above carries over. Wrap the plugin in an extension:
import { Extension } from "@tiptap/core";
export const GrammarIssues = Extension.create({
name: "grammarIssues",
addProseMirrorPlugins() {
return [proofreadPlugin()];
},
});Register it with the editor and drive scheduleProofread from TipTap's onUpdate callback. TipTap documents are usually rich — multiple block types, marks, lists — so the offsetToPos walker matters more here than in a plain-text schema: it maps API offsets to positions regardless of how the text is split across nodes. Keep the request text and the validation consistent by using one source of truth (for example, state.doc.textBetween(undefined, undefined, "\n") with newline separators counted in both places).
Where to go from here
This integration covers short-to-medium documents with a simple request loop. Two upgrades are worth knowing about as documents grow:
- Incremental proofreading — pass a stable
textIdper document and the SDK re-proofreads only the regions that changed, so a 50,000-character manuscript doesn't cost a full check on every keystroke. The response still covers the whole document, so the decoration set can be rebuilt from it exactly as above. - Streaming mode — receive complete edit batches as sections of a long document finish, and show the first suggestions while the rest are still processing.
The unchanging principle: the API returns inspectable edits, your editor owns the experience. Decorations, popovers, accept and dismiss — all of it stays ordinary ProseMirror, which means it composes with the rest of your schema, your plugins, and your tests. Create a free test key and wire up the loop above in an afternoon.
Still deciding which API to put behind it? The grammar checker API comparison reviews the seven options developers actually evaluate, on correction quality, latency, edit format, and price.