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. The quickstart has a cross-platform copy script and where to run it.
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, type EditorOptions } 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…",
} satisfies EditorOptions; // catches a mistyped option name
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.
Setup is the right place for one editor on a page that stays put. With several editors, or an editor in a dialog or route that opens and closes, create the spellchecker once in a module instead and pass it to each editor (see Editors that open and close). When the component unmounts, the wrapper destroys its editor; the spellchecker stays yours.
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<InstanceType<typeof SqwigEditor> | null>(null);
// later
sq.value?.editor?.getHTML();
sq.value?.editor?.setHTML("<p>Replaced</p>");
sq.value?.editor?.focus();
Typed this way, sq.value?.editor is Editor | null under strict TypeScript (1.10.7 and later). editor is null until mount. See editor methods and events.
Loading saved content
options.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 component 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:
<script setup lang="ts">
import { computed, ref } from "vue";
import { SqwigEditor } from "@sqwig/vue";
import { createSpellChecker, type EditorOptions } from "@sqwig/core";
const props = defineProps<{ id: string }>();
type Posting = { id: string; title: string; descriptionHtml: string };
const spell = createSpellChecker({ assetBaseUrl: "/sqwig/" });
const posting = ref<Posting | null>(null);
fetch(`/api/postings/${props.id}`)
.then((r) => (r.ok ? r.json() : { id: props.id, title: "", descriptionHtml: "" })) // 404: a new posting
.then((p: Posting) => (posting.value = p));
// Built in script, once per loaded record: the editor reads it only at mount.
const options = computed(() =>
posting.value
? ({ license: "free", spell, initialHTML: posting.value.descriptionHtml } satisfies EditorOptions)
: null,
);
function save(html: string) { /* your persistence */ }
</script>
<template>
<SqwigEditor
v-if="posting && options"
:key="posting.id"
:options="options"
@change="save"
/>
<p v-else>Loading…</p>
</template>
To replace content in an editor that is already on screen, such as a Revert button, call editor.setHTML(html) through the template ref. Behind v-if the ref fills in when the editor mounts, so read sq.value?.editor at the moment you need it, in a click handler for example. To act as soon as it mounts, watch the ref with { flush: "post" }.
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 v-html. 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 on the exposed editor 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 sq = ref<InstanceType<typeof SqwigEditor> | null>(null);
const message = ref("");
async function exportWord() {
const blob = await sq.value?.editor?.exportDocx();
if (blob) downloadBlob(blob, "job-posting.docx");
else message.value = "Export is not available right now.";
}
<!-- template -->
<button @click="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 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/ 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 && vite build"
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 shallowRef or a plain variable: ref() deep-proxies an object exactly as reactive() does.
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, in a module (a small spell.ts that exports it), 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