Vue rich text editor with built-in spellcheck
@sqwig/vue 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 Vue 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/vue
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.
cp -r node_modules/@sqwig/core/dist public/sqwig
Full detail in the quickstart.
Basic usage
<SqwigEditor> is a thin, uncontrolled wrapper. It takes a single options prop, the same object createEditor takes, read once at mount.
<script setup lang="ts">
import { SqwigEditor } from "@sqwig/vue";
import { createSpellChecker } from "@sqwig/core";
// Create ONCE, in setup. Inline in the template runs on every render,
// and the spellchecker owns a Web Worker and a WASM instance.
const options = {
license: "free",
spell: createSpellChecker({ assetBaseUrl: "/sqwig/" }),
placeholder: "Say something…",
};
function save(html: string) { /* your persistence */ }
</script>
<template>
<SqwigEditor :options="options" @change="save" />
</template>
Creating options in <script setup> is the single most important line on this page. It runs once per component instance, so the spellchecker, with its Worker and WASM engine, is created once. An object literal inside the template would be rebuilt on every render.
One options prop, three emits
Unlike the React wrapper, options do not travel as individual props: Vue coerces an absent Boolean prop to false, which would silently flip editor defaults like toolbar. One object avoids the trap and matches createEditor exactly.
| Prop | Type | Notes |
|---|---|---|
options | EditorOptions | Required. Read once at mount. license is required inside it; see the license option. Every field is in the full options table. |
class and style need no props at all; Vue's attribute fallthrough puts them on the wrapper element automatically.
| Emit | Payload | Notes |
|---|---|---|
@change | html: string | Fires on every content change, with the serialised HTML. |
@selection-change | (none) | Selection moved. Read state off the exposed editor. |
@spell-state | state: SpellState | Spellchecker readiness changed; useful for a loading affordance. |
The template ref exposes the editor
The component exposes the complete core Editor instance: not a subset, and not a Vue-flavoured shim. Anything the core can do, you can do.
<SqwigEditor ref="sq" :options="options" />
// in script setup:
const sq = ref();
// later
sq.value?.editor?.getHTML();
sq.value?.editor?.setHTML("<p>Replaced</p>");
sq.value?.editor?.focus();
editor is null until mount. See editor methods and events.
Why there is no v-model
Deliberately. A two-way bound rich text editor has to reconcile a framework 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 @change.
In practice: hold the HTML wherever you need it for a save button, but do not feed it back in. To replace content programmatically, call setHTML on the exposed editor. One content model, same as the React wrapper.
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.
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. @vitejs/plugin-vue needs no special configuration: the published package is plain JavaScript, no SFC compilation involved.
Nuxt and server rendering
The editor needs a real DOM, a Web Worker and WebAssembly, so it is client-only. The component mounts the editor in onMounted, which never runs on the server. In Nuxt, wrap it in <ClientOnly> so the element itself stays out of the server render.
<ClientOnly>
<SqwigEditor :options="options" @change="save" />
</ClientOnly>
Assets go in public/sqwig/, same as Vite.
Common pitfalls
Options built inline in the template
:options="{ license: 'free', spell: createSpellChecker() }" rebuilds the object, spellchecker, Worker and all, on every render. Build it once in <script setup>, as above.
Changing options and nothing happening
The options object is read once, at mount. Mutating it later, or swapping in a new object, will not re-initialise the editor. Use the exposed editor for runtime changes, or force a remount with v-if if you genuinely need fresh options.
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.
Wrapping the editor in a reactive()
Do not put the exposed editor (or the options object holding a live spellchecker) inside reactive(): deep reactivity would proxy the editor's internal DOM references. The component already guards its own handle; keep yours in a plain ref.
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