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

terminal
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:

terminal
cp -r node_modules/@sqwig/core/dist public/sqwig

Full detail in the quickstart.

Basic usage

<SqwigEditor> is a thin, uncontrolled wrapper. Core options are props, read once at mount. It is StrictMode-safe.

Compose.tsx
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:

PropTypeNotes
classNamestringApplied to the wrapper element.
styleCSSPropertiesApplied to the wrapper element.
onChange(html: string) => voidFires on every content change, with the serialised HTML.
onSelectionChange() => voidSelection moved. Read state off the ref.
onSpellState(state: SpellState) => voidSpellchecker readiness changed — useful for a loading affordance.

The most commonly used inherited options:

PropTypeNotes
license"free" | stringRequired. See the license option.
spellSpellCheckerLike | Promise | nullPass null for a plain editor with no checking.
placeholderstringShown while empty.
initialHTMLstringStarting content. Read at mount only.
toolbarboolean | "fixed" | "floating""floating" is desktop-only; touch always gets the fixed bar.
theme"light" | "dark"
readOnlyboolean
maxLengthnumber
mode"live" | "manual""manual" checks on demand instead of as you type.
trustIndicatorboolean | { 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:

tsx
const editor = useRef<Editor>(null);

// later
editor.current?.getHTML();
editor.current?.setHTML("<p>Replaced</p>");
editor.current?.focus();

See editor methods and events.

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/ and point at them with a root-relative path. Vite serves public/ at the root in both dev and build, so /sqwig/ works in each without conditionals:

terminal
cp -r node_modules/@sqwig/core/dist public/sqwig

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 is client-only. In the App Router, mark the component "use client" and, if you are rendering it inside a server-rendered route, load it without SSR:

tsx
const Compose = dynamic(() => import("./Compose"), { ssr: false });

Assets go in public/sqwig/, same as Vite.

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.

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.

Where to go next