The grammar checkers people use every day — Grammarly, LanguageTool, and friends — are Chrome extensions at heart: find the text a user is editing, send it somewhere smart, and draw squiggly underlines under the problems. None of that requires a browser-vendor budget. With Manifest V3, a proofreading API, and one modern browser feature, you can build a working version in an afternoon.
This tutorial builds exactly that: an extension that watches editable fields on any page, proofreads them through AmberPen, and underlines issues without mutating the page's DOM — the detail that keeps you from breaking every React editor on the internet.
Why the DOM mutation rule matters
The naive way to underline a word is to wrap it in a <span>. On a page you control, fine. Inside someone else's web app, it's sabotage: frameworks reconcile the DOM against their own state, and your injected spans desynchronize the two. Editors lose selection, React throws, drafts get corrupted.
Chrome's answer is the CSS Custom Highlight API: you construct Range objects over the text you want to mark, register them in a highlight registry, and style them with a ::highlight() pseudo-element. The browser paints the underline; the DOM is never touched. It also handles repainting as the user types and scrolls, for free.
Project skeleton
Three files plus a manifest:
amberpen-extension/
├── manifest.json
├── background.js # service worker — talks to your API proxy
├── content.js # finds editors, maps offsets, paints highlights
└── highlight.css # ::highlight styling{
"manifest_version": 3,
"name": "AmberPen Grammar Checker",
"version": "0.1.0",
"background": { "service_worker": "background.js" },
"host_permissions": ["https://your-proxy.example.com/*"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["highlight.css"]
}
]
}Load it with chrome://extensions → Developer mode → Load unpacked.
Keep the API key off the client
An extension ships its source to every user, so any key inside it is public. The extension calls your small proxy; the proxy holds the AmberPen key. The proxy can be twenty lines:
import { createAmberPenClient } from "@amber-pen/sdk";
const amberPen = createAmberPenClient({
apiKey: process.env.AMBER_PEN_API_KEY!,
});
Bun.serve({
port: 3000,
async fetch(request) {
const url = new URL(request.url);
if (request.method !== "POST" || url.pathname !== "/proofread") {
return new Response("Not found", { status: 404 });
}
const { text } = await request.json();
const result = await amberPen.proofread({ text });
return Response.json({ edits: result.edits });
},
});The extension's background worker calls it. Routing the request through the service worker (rather than fetching from the content script) keeps the call in the extension's origin, where host_permissions applies, instead of the page's origin, where CORS does:
// background.js
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type !== "proofread") return;
fetch("https://your-proxy.example.com/proofread", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: message.text }),
})
.then((response) => response.json())
.then((data) => sendResponse(data))
.catch(() => sendResponse({ edits: [] }));
return true; // keep the channel open for the async response
});Find the text and map the offsets
AmberPen's edits are UTF-16 offsets into a single string. A contenteditable element, though, is a tree of text nodes. So the content script flattens the tree into one string while remembering which segment of that string lives in which node:
// content.js
function collectSegments(root) {
const walker = root.ownerDocument.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
);
const segments = [];
let text = "";
let node;
while ((node = walker.nextNode())) {
segments.push({ node, start: text.length });
text += node.data;
}
return { text, segments };
}
function locate(segments, position) {
for (const segment of segments) {
const end = segment.start + segment.node.data.length;
if (position <= end) {
return { node: segment.node, offset: position - segment.start };
}
}
return undefined;
}collectSegments produces the exact string you send to the API; locate converts an offset in that string back into a (node, offset) pair the browser understands. Because JavaScript strings, Node.data, and the API all count in UTF-16 code units, no index translation is needed anywhere — the same property that makes the offsets work with slice.
Paint the highlights
Convert each edit into a Range, collect them in a Highlight, and register it under one name:
function paintEdits(segments, edits) {
const ranges = [];
for (const edit of edits) {
if (edit.start === edit.end) continue; // pure insertions — see below
const from = locate(segments, edit.start);
const to = locate(segments, edit.end);
if (!from || !to) continue;
const range = new Range();
range.setStart(from.node, from.offset);
range.setEnd(to.node, to.offset);
ranges.push(range);
}
CSS.highlights.set("amberpen-issue", new Highlight(...ranges));
}/* highlight.css */
::highlight(amberpen-issue) {
text-decoration: underline wavy #d97706;
text-underline-offset: 3px;
}Pure insertions — an edit whose start equals end, suggesting missing text — have no range to underline. Skipping them keeps this tutorial honest; a production extension might paint a caret marker instead.
Wire up the proofread loop
Listen for input, debounce, version every request, and never paint a stale response:
let version = 0;
let timer;
document.addEventListener(
"input",
(event) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.isContentEditable) return;
const current = ++version;
clearTimeout(timer);
timer = setTimeout(() => {
const { text, segments } = collectSegments(target);
if (text.trim().length === 0) {
CSS.highlights.delete("amberpen-issue");
return;
}
chrome.runtime.sendMessage({ type: "proofread", text }, (response) => {
if (current !== version || !response) return; // a newer check is coming
paintEdits(segments, response.edits ?? []);
});
}, 500);
},
true, // capture: editors sometimes stop propagation
);The version counter is the whole correctness story: typing invalidates in-flight requests, and only the newest response is allowed to paint. This is the same debounce-version-discard loop the grammar checker architecture guide describes for editor integrations.
What about <textarea>?
The Highlight API paints ranges in the document tree — and a textarea's content lives in a shadow tree you can't range over. The classic workaround is the mirror div: position an invisible <div> exactly over the textarea, copy its text and typography into it, and underline inside the mirror. It works (it's how several production extensions do it), but it means re-syncing scroll, fonts, and padding on every resize. For an afternoon build, cover contenteditable first — it's where most long-form writing on the modern web happens — and add the mirror when users ask for plain-textarea support.
Honest limitations, and where to go next
This extension detects and underlines; it doesn't apply corrections. Clicking a suggestion and rewriting text inside an arbitrary site's editor is the genuinely hard part of this product category, because programmatic edits fight the host app's own state management. In products you control, that problem disappears — your editor owns the transaction, as the TipTap/ProseMirror integration shows.
A few upgrades when the afternoon stretches into a week:
- Vocabulary — pass
dictionaryandproperNounsfields so the checker respects the terminology of the sites your users work on. See custom dictionaries. - A toggle and a privacy notice — be upfront that page text goes to your proxy, and give users an off switch per site.
- Scale primitives — for very long documents, streaming mode delivers edit batches progressively.
The takeaway is that the moat was never the extension scaffolding — a manifest, a content script, and a highlight registry fit in an afternoon. The moat is correction quality you can trust under real text, and that's the part a free test key lets you verify on your own pages, today.
If you haven't picked a checker yet, the grammar checker API comparison covers the seven worth evaluating — including which ones return the structured edits this extension depends on, and which only return rewritten text.