React rich text editor with built-in spellcheck
@sqwig/react gives you a rich text editor whose spellcheck actually works: red underlines, suggestions on click, running entirely in the browser. No API key, no per-seat pricing, and no text leaving the page.
Why this is unusual
Most React rich text editors treat spellcheck as somebody else's job. You get formatting, lists and links, and then you either accept the browser's native checker (which you cannot style, position or control, and which disappears the moment you set contenteditable to a custom implementation), or you bolt on a paid service that sends every keystroke to someone's server.
Sqwig ships the dictionary. A SymSpell engine compiled to WebAssembly runs in a Web Worker beside your editor, checks as the user types, and renders its own underlines and suggestion popover. Nothing is transmitted, so there is no request to fail, no latency to hide, and nothing to put in a DPA.
Install
npm i @sqwig/core @sqwig/react
Then copy the three runtime assets into a statically served folder: the WASM engine, its worker, and the dictionary chunks. A bundler cannot inline them because they are fetched at runtime. The quickstart has a cross-platform copy script and where to run it.
Basic usage
<SqwigEditor> is a thin, uncontrolled wrapper. Core options are props, read once at mount. It is StrictMode-safe.
import { useMemo, useRef } from "react";
import { SqwigEditor } from "@sqwig/react";
import { createSpellChecker, type Editor } from "@sqwig/core";
export function Compose() {
const editor = useRef<Editor>(null);
// Create ONCE. Inline in JSX spawns a Worker on every render.
const spell = useMemo(() => createSpellChecker({ assetBaseUrl: "/sqwig/" }), []);
return (
<SqwigEditor
ref={editor}
spell={spell}
license="free"
placeholder="Say something…"
onChange={(html) => save(html)}
/>
);
}
That useMemo is the single most important line on this page. The spellchecker owns a Web Worker and a WASM instance; creating it inline in JSX creates a new one on every render, and you will watch your tab's memory climb.
Props
SqwigEditorProps extends EditorOptions, so every core option is available as a prop. These are the React-specific additions:
| Prop | Type | Notes |
|---|---|---|
className | string | Applied to the wrapper element. |
style | CSSProperties | Applied to the wrapper element. |
onChange | (html: string) => void | Fires on every content change, with the serialised HTML. |
onSelectionChange | () => void | Selection moved. Read state off the ref. |
onSpellState | (state: SpellState) => void | Spellchecker readiness changed; useful for a loading affordance. |
The most commonly used inherited options:
| Prop | Type | Notes |
|---|---|---|
license | "free" | string | Required. See the license option. |
spell | SpellCheckerLike | Promise | null | Pass null for a plain editor with no checking. |
placeholder | string | Shown while empty. |
initialHTML | string | Starting content. Read at mount only. |
toolbar | boolean | "fixed" | "floating" | ids | { mode, items } | "floating" is desktop-only; touch always gets the fixed bar. An id array or preset picks the buttons; see the options table. |
theme | "light" | "dark" | |
readOnly | boolean | |
maxLength | number | |
mode | "live" | "manual" | "manual" checks on demand instead of as you type. |
trustIndicator | boolean | { text?, wordCount? } | The footer row under the editor. |
Every option is in the full options table.
The ref is the editor
The wrapper forwards a ref to the complete core Editor instance: not a subset, and not a React-flavoured shim. Anything the core can do, you can do:
const editor = useRef<Editor>(null);
// later
editor.current?.getHTML();
editor.current?.setHTML("<p>Replaced</p>");
editor.current?.focus();
The ref is set when the editor mounts and cleared when it unmounts, at which point the wrapper destroys the editor (the spellchecker stays yours). To act the moment it exists, pass a callback ref: ref={(ed) => { … }} receives the editor on mount and null on unmount. See editor methods and events.
Loading saved content
initialHTML is read once, at mount. Content from a fetch usually arrives after the editor has mounted, and by then a new initialHTML is ignored: the editor stays empty, and the next save writes that empty document over the stored one. The wrapper logs a console warning when this happens.
Render the editor once the data is ready, and key it by record so that opening a different record mounts a fresh editor:
const [posting, setPosting] = useState<Posting | null>(null);
useEffect(() => {
fetch(`/api/postings/${id}`).then((r) => r.json()).then(setPosting);
}, [id]);
if (!posting) return <p>Loading…</p>;
return (
<SqwigEditor
key={posting.id}
ref={editor}
spell={spell}
license="free"
initialHTML={posting.descriptionHtml}
/>
);
Keep the useMemo for spell above the early return, as hooks require. To replace content in an editor that is already on screen, such as a Revert button, call editor.current?.setHTML(html).
Whether setHTML fires change, whether undo can remove a load, what an empty editor returns, and sharing one spellchecker: saving & loading.
Displaying saved content
To show a saved document outside the editor, render the stored HTML with dangerouslySetInnerHTML. Re-sanitize it on your server before it goes back out, as the security model asks, with options that keep the formatting. Formatting travels as inline styles and the output uses no class names, so it displays correctly without Sqwig's stylesheet. The one exception is a styled table, which carries data-sqwig-style="banded" or "tinted": style it with your own CSS, or leave it plain.
Export to Word and PDF
Paid plans. The toolbar buttons need no code. For your own button, call the method through the ref and save the Blob it returns with a copy of the downloadBlob function from the export section (a few lines of your own code, not a Sqwig export):
const editor = useRef<Editor>(null);
const [message, setMessage] = useState("");
async function exportWord() {
const blob = await editor.current?.exportDocx();
if (blob) downloadBlob(blob, "job-posting.docx");
else setMessage("Export is not available right now.");
}
<button onClick={exportWord}>Export to Word</button>
exportPdf() works the same way. Both resolve null on the Free tier or if the export fails, and never throw.
Why it is uncontrolled
There is no value prop, and that is deliberate. A controlled rich text editor has to reconcile a React render against a live DOM selection on every keystroke, and the usual result is a cursor that jumps to the end of the document mid-word. Sqwig keeps the document in the editor and hands you HTML through onChange.
In practice: hold the HTML in state if you need it for a save button, but do not feed it back in as a prop. To replace content programmatically, call setHTML on the ref.
Vite
Copy the assets into public/sqwig/ with the quickstart's copy script and pass that root-relative path as assetBaseUrl; the bundler moves Sqwig's JavaScript away from the assets, so the default path would miss. Vite serves public/ at the root in both dev and build, so /sqwig/ works in each without conditionals:
"dev": "node scripts/copy-sqwig.mjs && vite",
"build": "node scripts/copy-sqwig.mjs && tsc -b && vite build"
Do not import the worker or the .wasm. Vite would try to fingerprint and rewrite them, and the runtime fetch then misses.
Next.js and server rendering
The editor needs a real DOM, a Web Worker and WebAssembly, so it runs in the browser only. In the App Router every route renders on the server first, a static export included, so load the component that holds the editor with dynamic(..., { ssr: false }). Next accepts ssr: false only in a client component, and page.tsx is a server component, so put the dynamic import in a small "use client" file that the page renders:
// app/compose-client.tsx
"use client";
import dynamic from "next/dynamic";
const Compose = dynamic(() => import("./Compose"), { ssr: false }); // your component with <SqwigEditor>
export default function ComposeClient() { return <Compose />; }
// app/page.tsx stays a server component
import ComposeClient from "./compose-client";
export default function Page() { return <ComposeClient />; }
Create the spellchecker in a module that only Compose and its children import, so it is created in the browser: on the server there is no page to load the dictionary from. Importing @sqwig/react or @sqwig/core on the server is safe; creating a spellchecker there is not.
Assets go in public/sqwig/, same as Vite. Next serves public/ at the root, in a static export (output: "export") as well:
"dev": "node scripts/copy-sqwig.mjs && next dev",
"build": "node scripts/copy-sqwig.mjs && next build"
Common pitfalls
A new Worker on every render
Creating the spellchecker inline in JSX. Wrap it in useMemo with an empty dependency array, as above.
Changing a prop and nothing happening
Options are read at mount. Changing placeholder or toolbar later will not re-initialise the editor. Use the ref for runtime changes. For content that arrives from a fetch, see loading saved content.
Underlines never appear
The editor mounted but the assets 404'd. Check the Network panel for spell.worker.js, spell_wasm_bg.wasm and dict/. The editor deliberately keeps working without them, so this fails quietly.
Double mounts in development
StrictMode mounts twice on purpose. The wrapper handles it. If you see two editors, something outside the wrapper is duplicating the element.
Editors that open and close
A spellchecker created inside a component is rebuilt each time that component mounts, so a dialog or route that shows the editor again reloads the engine and dictionary. Create it once for the app, at module scope, above your components, and pass the same checker to every editor. A checker your app no longer references is released automatically, so per-component checkers do not pile up; they only cost the reload.
Where to go next
- Quickstart: installation, assets, the license option
- createEditor options: the full table
- Styling and theming: CSS custom properties
- Spellcheck and suggestions: how checking behaves
- Privacy: what the component transmits, which is nothing