A 26KB Terminal in the Browser: wterm, DOM Rendering & the Ghostty Core (2026)
wterm renders terminals to the DOM with a ~26KB Zig/WASM core — native copy, find, a11y. How to embed it in a Next 16 app, safely.
Copy text out of a browser terminal and you get either garbage leading whitespace or a screenshot workflow. That has been the tax every web terminal paid since the early 2010s, because canvas rendering is fast — but a canvas is a picture, not text. Vercel Labs' open-source wterm ("dub-term") takes the opposite bet: render rows to real DOM nodes, and selection, clipboard, find-in-page, and screen readers come free. The core parser is still written in Zig and compiled to WASM — the built-in binary is about 26 KB in a release build — so you keep near-native emulation without shipping a JS escape-sequence megapack.
TL;DR
- wterm is a terminal emulator for the web (vercel-labs/wterm, Apache-2.0) that renders terminal rows to the DOM instead of a canvas — native text selection, copy/paste, browser find, and screen reader support work directly on the mounted rows.
- The built-in core is a VT100/VT220/xterm escape-sequence parser written in Zig, compiled to a ~26 KB
.wasmbinary (release build). It is shipped as a real binary that supports browsers can compile during download. - An opt-in libghostty backend (
@wterm/ghostty) swaps in the Ghostty terminal's core compiled to WASM for full VT compliance: grapheme strings (combining marks, ZWJ emoji), soft-wrap metadata for copy, OSC 133 prompt state, and Kitty image support. - It ships framework bindings including
@wterm/react(a React component +useTerminalhook), a WebSocket transport for PTY backends, a Worker core that parses off the main thread, and dirty-row rendering so only touched rows re-render per frame. - In Next.js 16: load it as a client-only component behind
next/dynamic(ssr: false) +<Suspense>, so the WASM and terminal JS never touch the initial bundle. - Two deployment gotchas: CSP needs
'wasm-unsafe-eval'inscript-src, and a connected terminal is only as safe as the PTY backend you point it at — never expose an unauthenticated shell.
What wterm actually is
The repo is a pnpm/turborepo monorepo with a package structure worth reading directly, because the layers are cleanly split:
| Package | What it does |
|---|---|
@wterm/core | Headless WASM bridge, TerminalCore interface, WebSocket transport |
@wterm/dom | DOM renderer and input handler — vanilla JS terminal |
@wterm/react | React component + useTerminal hook (TypeScript) |
@wterm/ghostty | Full-featured VT emulation core powered by libghostty |
@wterm/just-bash | In-browser Bash shell powered by just-bash 3 |
@wterm/markdown | Render Markdown in the terminal |
Splitting emulation (core) from rendering (dom) matters architecturally. It is the same shape as a virtual DOM: one component maintains terminal state, another commits it to pixels. The renderer does dirty-row tracking — only touched rows are re-rendered each frame via requestAnimationFrame, partial redraws reuse unchanged cell spans, and text changes reuse text nodes when styles and column widths match. There is also a WorkerCore that parses escape sequences off the main thread, and an opt-in WebGL2 renderer ("full-screen redraws, images, and large pane grids from one shared context") for when mounted rows get genuinely heavy. The default, though, is plain DOM — which is the point.
One honest framing note: wterm is a Vercel Labs experiment, at ~3.7k GitHub stars at the time of writing. Read the API docs before betting a revenue-facing product on it (also our rule for any dependency that moves fast).
Why DOM rendering changes the feature set
xterm.js and its canvas-compatible cousins made web terminals viable, and nothing about their speed is broken. What canvas costs you is the browser's text toolkit. wterm's README is essentially a list of the gaps it closes:
- DOM rendering — native text selection, clipboard, browser find, and screen reader support for mounted rows. Ctrl+F finds text inside scrollback because it is text.
- Terminal-aware copy — selected text preserves explicit newlines and complete Unicode cells, includes block glyphs, and joins confirmed soft wraps — solving the "copied output is one giant line or has phantom spaces" problem.
- Full-history Find — time-sliced plain-text search with match counts, highlights, and next/previous navigation across retained output; the Ghostty core searches across soft wraps.
- Accessibility — the editable control exposes host labels and descriptions to assistive technology, follows the host tab order, and Escape+Tab returns focus to the page. Optional bounded, polite screen-reader output announcements.
- Windowed scrollback — a configurable ring buffer with a bounded visible DOM window, so long history does not mean unbounded
textContentnodes.
The performance-shaped features exist precisely because the browser now controls text: synchronized output (mode 2026) blocks paint atomically, inactive panes skip paints, and sustained-output benches (up to 100 MiB soak runs in the repo's harness) fail on text loss or unbounded DOM growth. In other words, the DOM approach is rat race-tested rather than assumed.
The Ghostty core, and what "26KB" applies to
Two facts get conflated online, so pin them down:
- The ~26 KB figure is the built-in Zig core — the VT100/VT220/xterm escape-sequence parser compiled to a
.wasmbinary in a release build ("Zig + WASM core — ... compiled to a ~26 KB.wasmbinary (release build)"). It is deliberately lightweight and covers the common-case xterm-compatible surface. - libghostty is the opt-in heavyweight.
@wterm/ghosttyembeds the core of Ghostty — the Zig-based GPU terminal emulator — compiled to WASM, pinned to upstream v1.3.1 in the adapter. You get real grapheme strings (combining marks and ZWJ emoji survive rendering and scrollback), Kitty terminal image support (PNG/RGB/RGBA with a 32 MiB-per-image hard cap), soft-wrap metadata usable by copy and search, OSC 133 prompt/running/completion state, OSC 7 directory reports, and full 24-bit color plus exotic underline styles.
The interesting load trick: shared WASM loading — both cores reuse compiled modules while keeping separate terminal memory, and supported browsers compile separately served binaries during download. Translation: compile time hides inside network time on streaming-compile-capable browsers. If you run multiple terminals (agent consoles love split panes — wterm's own workspace does up to four side by side), instantiation cost amortizes rather than multiplies.
If you just want a terminal in a docs page, the Zig core plus @wterm/just-bash (an in-browser Bash with history search, cursor-aware tab completion, and Ctrl+C interruption) needs no backend at all. If users will hit vim or htop against a real machine, you want Ghostty compliance and a PTY behind a WebSocket.
Embedding in a Next 16 app (architecture sketch)
The upstream repo ships a Next.js example (examples/nextjs), so this shape is the intended one. What follows is an illustrative architecture — mount/prop names below are illustrative; consult the @wterm/react docs for the current component API.
Layer 1: the terminal is inherently client-side (DOM + WASM + keyboard). Keep it out of the server graph entirely:
// components/terminal-pane.tsx
"use client";
// [unverified] Mount-prop names are illustrative — check the @wterm/react docs
// for the current component API before shipping.
import { useEffect, useRef } from "react";
export default function TerminalPane({ socketUrl }: { socketUrl: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let dispose: (() => void) | undefined;
void (async () => {
const { createTerminal } = await import("./wterm-setup");
dispose = await createTerminal(ref.current!, socketUrl);
})();
return () => dispose?.();
}, [socketUrl]);
return <div ref={ref} aria-label="Session terminal" />;
}
Everything WASM-touching lives in wterm-setup.ts, which only imports @wterm/core/@wterm/react-adjacent internals — so the bundler code-splits the ~26 KB .wasm fetch and its JS bridge away from anything above the fold:
// components/wterm-setup.ts — only ever imported dynamically
"use client";
// [unverified] Illustrative wiring: the exact imports and mount call come from
// the @wterm/react / @wterm/core package docs. The transport contract (binary
// framing + reconnect over WebSocket to a PTY backend) is documented upstream.
export async function createTerminal(container: HTMLElement, socketUrl: string) {
const { mountTerminal } = await import("@wterm/react/wasm");
return mountTerminal(container, { url: socketUrl });
}
Layer 2: gate it in the page behind a boundary, the same way you gate any viewer-only, third-byte-of-the-page feature:
// app/console/[sessionId]/page.tsx
import { Suspense } from "react";
import TerminalPaneFetcher from "@/components/terminal-pane-fetcher";
export default async function ConsolePage({
params,
}: {
params: Promise<{ sessionId: string }>;
}) {
const { sessionId } = await params; // Next 16: params is a Promise
return (
<main>
<h1>Agent console</h1>
<Suspense fallback={<div className="term-shell" aria-busy="true" />}>
<TerminalPaneFetcher sessionId={sessionId} />
</Suspense>
</main>
);
}
// components/terminal-pane-fetcher.tsx — must be a Client Component in the
// App Router: next/dynamic with `ssr: false` is not allowed in Server Components.
"use client";
import dynamic from "next/dynamic";
const TerminalPane = dynamic(() => import("@/components/terminal-pane"), {
ssr: false,
});
export default function TerminalPaneFetcher({
sessionId,
}: {
sessionId: string;
}) {
// The WS URL construction is app-specific; auth must come from your session,
// never from a guessable public token.
return <TerminalPane socketUrl={`/api/pty/${sessionId}`} />;
}
Why this shape and not useEffect-then-setState at the top of a Server Component: the WASM fetch is the slowest step in first paint of a terminal. A Suspense fallback streams a skeleton shell immediately (PPR-friendly), while the dynamic chunk, the .wasm bytes, and the worker come in behind it. Nothing terminal-shaped touches SSR, so hydration stays boring and your static shell gets cached normally.
CSP, sandboxing, and the part that can actually hurt you in production
Three deployment notes, in order of insertion pain:
1. CSP: 'wasm-unsafe-eval'. Compiling WASM counts as code evaluation. If you run the nonce-based CSP pattern from the Next.js docs (script-src 'self' 'nonce-…' 'strict-dynamic'), your terminal either fails silently at instantiate or throws a CSP violation — Next's own CSP troubleshooting list calls this out explicitly: "WebAssembly: Add 'wasm-unsafe-eval' if using WebAssembly." $('wasm-unsafe-eval') is the narrow escape hatch — it permits WASM compilation without allowing JS eval — so prefer it over 'unsafe-eval':
// next.config.ts — headers() CSP excerpt (static policy; add nonces if you use stricter-dynamic)
script-src 'self' 'wasm-unsafe-eval';
connect-src 'self' wss:; // the WebSocket to your PTY/session backend
2. The renderer is safe; the backend is not. An emulator parses escape sequences into buffered text — by design it cannot turn received bytes into script, and DOM rendering means no canvas injection surface either. The risk is entirely on the session side: if your WebSocket handler opens a real PTY, anyone with the URL has a shell. Make provisioning match your auth model — minted per-session IDs tied to an authenticated user, short TTLs, an ephemeral container per session (no shared state, no secrets in the image), and never point @wterm/just-bash at anything that matters. If you run your core in a Worker (the WorkerCore path), you additionally isolate parsing from the main thread, which is defense-in-depth and responsiveness in one.
3. Budget the runtime, not the bytes. 26 KB was never the whole story — the bridge JS, themes, and optional Ghostty WASM are each their own decision. The upside is that wterm gives you knobs (built-in core vs Ghostty, DOM vs WebGL2 renderer, on-main vs Worker core) instead of forcing one trade-off. Measure with the repo's own benchmark harnesses before locking choices in.
When you actually need this
Not every SaaS needs a terminal. The cases where one earns its keep are surprisingly narrow — but when they hit, they hit hard:
- Docs sandboxes — a "try the CLI" pane on a product page, powered by
@wterm/just-bashwith zero backend. This is the cheapest real win: static, cacheable, and it makes the docs interactive without touching a server. - Agent consoles — 2026's default support story is "an agent ran commands and fixed it." Users distrust black boxes; a terminal pane replaying what the agent typed and received is trust rendered as pixels. wterm even ships read-only
readText()snapshots and a WebMCP mode so in-page agents can check terminal output programmatically. - E-learning and workshops — real
vim/tmuxmuscle memory in the browser, with DOM rendering making copy-from-lesson-to-terminal actually work for students. - Post-mortem/diagnostic panels — rectangle selection over server logs (Alt/Option-drag copies columns preserving spaces) and full-history find are quietly great for anyone investigating an incident.
Skip it if you are just showing logs: a styled <pre> with the same escape-sequence-stripping in lib/markdown.ts-style render path is cheaper and cacheable. The moment users must type, reach for wterm.
Wrap-up
- wterm's bet — DOM rows + a small Zig/WASM core (~26 KB built-in), with libghostty as opt-in full-compliance — trades a canvas's raw throughput for the browser's native text stack: selection, clipboard, find, and accessibility, all working on real text nodes. For user-facing terminals, that trade is usually correct.
- In Next.js 16, embed it as a client-only island:
next/dynamicwithssr: falseinside a<Suspense>boundary in a Client Component, WASM setup in a lazy chunk, and the same pattern doubly for any PTY WebSocket route. - Spend your security budget on the backend (per-session PTYs, auth-tied provisioning, ephemeral containers) and one line on CSP (
'wasm-unsafe-eval'), then decide boring-vs-compliant via the two-core switch only where users really needvim, images, or exact Unicode copy.
Sources / further reading
The project README and package structure (including the bundled Next.js example) are at github.com/vercel-labs/wterm, with a live Ghostty-powered demo and per-package docs at wterm.dev. The CSP guidance — including the requirement to add 'wasm-unsafe-eval' when using WebAssembly — comes from the official Next.js Content Security Policy guide. The App Router rules used above (next/dynamic with ssr: false restricted to Client Components, Promise-based params in Next 16, Suspense streaming mechanics) are covered in the Next.js App Router documentation. Community discussion and independent coverage of wterm's DOM-vs-canvas trade-off are collected in the Hacker News thread on the launch.