Back to Scaling Node.js applications guides

W-Term: A DOM-Based Web Terminal from Vercel with Ghostty Core Support

Stanley Ulili
Updated on June 1, 2026

W-Term is a web terminal emulator from Vercel that renders to the DOM rather than to an HTML <canvas>. Each terminal row becomes a div.term-row with actual text content, which means native browser text selection, copy-paste, Ctrl+F search, and screen reader parsing all work without any additional implementation.

The default rendering engine is a Zig-compiled WebAssembly binary of approximately 12KB. For applications requiring more complete VT-100 emulation, an optional Ghostty core (~400KB) is available as a drop-in replacement.

Canvas-based terminals and why DOM rendering matters

Xterm.js, the dominant web terminal library, renders to a canvas element. The browser treats the canvas as a pixel bitmap with no semantic understanding of the text drawn inside it. This means standard browser features do not work natively: text selection, copy, find, and screen reader support all require re-implementation in JavaScript. The results are functional but not equivalent to native browser behavior.

W-Term's DOM approach solves this by keeping the terminal content as actual text nodes. The trade-off is that naive DOM manipulation can be slow. W-Term avoids this with dirty-row tracking: it uses requestAnimationFrame and only re-renders the specific rows that changed since the last frame, keeping performance competitive even for applications like htop that update frequently.

htop running in W-Term with browser developer tools open showing the corresponding div elements for each row

Full-stack setup

Building a functional web terminal requires a frontend (W-Term) and a backend that runs a real shell process and connects to the frontend via WebSocket.

Frontend: React with W-Term

Create a Vite React project:

 
npm create vite@latest wterm-demo -- --template react-ts
 
cd wterm-demo && npm install

Install W-Term packages:

 
npm install @wterm/core @wterm/react @wterm/dom
  • @wterm/core: framework-agnostic logic including the WebSocket transport
  • @wterm/dom: the DOM rendering engine
  • @wterm/react: the <Terminal /> React component

Basic component:

src/App.tsx
import { Terminal } from "@wterm/react";
import "@wterm/react/css";

function App() {
  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <Terminal />
    </div>
  );
}

export default App;

At this point, the terminal renders but is not connected to a shell. Typing produces no output.

Backend: Node.js PTY server

The backend spawns a shell process using node-pty, which creates a pseudoterminal (PTY) master-slave pair. Node.js writes to the master end (appearing as stdin to the shell) and reads from the master end (capturing stdout and stderr). A WebSocket server bridges this to the browser.

 
mkdir wterm-server && cd wterm-server && npm init -y
 
npm install ws node-pty
server.mjs
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import * as pty from "node-pty";

const PORT = process.env.PORT || 3001;
const SHELL = process.env.SHELL || "bash";

const server = createServer((_, res) => {
  res.writeHead(404);
  res.end();
});

const wss = new WebSocketServer({ server, path: "/api/terminal" });

wss.on("connection", (ws) => {
  const proc = pty.spawn(SHELL, [], {
    name: "xterm-256color",
    cols: 80,
    rows: 24,
    cwd: process.env.HOME,
    env: process.env,
  });

  // PTY output -> WebSocket client
  proc.onData((data) => {
    if (ws.readyState === ws.OPEN) ws.send(data);
  });

  // WebSocket client input -> PTY
  ws.on("message", (data) => {
    const message = data.toString();
    const resizeMatch = message.match(/^\x1b\[RESIZE:(\d+);(\d+)\]$/);
    if (resizeMatch) {
      proc.resize(parseInt(resizeMatch[1], 10), parseInt(resizeMatch[2], 10));
    } else {
      proc.write(message);
    }
  });

  const handleClose = () => proc.kill();
  ws.on("close", handleClose);
  ws.on("error", handleClose);
});

server.listen(PORT, () => {
  console.log(`Terminal server running on http://localhost:${PORT}`);
});

Full Node.js server script in a code editor

The resize handling uses a custom escape sequence (\x1b[RESIZE:cols;rows]) to distinguish resize events from regular input in the same WebSocket channel.

Connecting the frontend to the backend

src/App.tsx
import { useRef } from "react";
import { Terminal } from "@wterm/react";
import { WebSocketTransport } from "@wterm/core";
import { WTerm } from "@wterm/dom";
import "@wterm/react/css";

const WS_URL = "ws://localhost:3001/api/terminal";

function App() {
  const transportRef = useRef<WebSocketTransport | null>(null);

  const handleReady = (wt: WTerm) => {
    const transport = new WebSocketTransport({
      url: WS_URL,
      reconnect: true,
      maxReconnectDelay: 5000,
    });
    transportRef.current = transport;

    transport.onData((data) => wt.write(data));
    transport.onOpen(() => {
      transport.send(`\x1b[RESIZE:${wt.cols};${wt.rows}]`);
    });
    transport.connect();
  };

  const handleData = (data: string) => transportRef.current?.send(data);

  const handleResize = (cols: number, rows: number) => {
    transportRef.current?.send(`\x1b[RESIZE:${cols};${rows}]`);
  };

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <Terminal
        cols={80}
        rows={24}
        autoResize={true}
        onReady={handleReady}
        onData={handleData}
        onResize={handleResize}
      />
    </div>
  );
}

export default App;

Final App.tsx file showing the WebSocket connection logic

Start the backend:

 
node server.mjs

Start the frontend:

 
npm run dev

The terminal now connects to the shell. Typing ls, htop, or any other shell command works as expected.

Ghostty core

The default ~12KB Zig core handles standard terminal output well. For complex TUI applications, full Unicode grapheme cluster support, or advanced terminal attributes, the Ghostty core provides more complete VT-100 emulation at the cost of a larger bundle (~400KB).

Side-by-side comparison of color rendering between the default core and the Ghostty core showing Ghostty's superior accuracy

Switching to Ghostty

 
npm install @wterm/ghostty

Add a script to copy the WASM file to the public directory before building:

package.json
"scripts": {
  "dev": "npm run copy-wasm && vite",
  "build": "npm run copy-wasm && tsc && vite build",
  "copy-wasm": "cp ./node_modules/@wterm/ghostty/dist/ghostty-vt.wasm ./public/"
}

Load the core asynchronously and pass it to the Terminal component:

src/App.tsx
import { useEffect, useState, useRef } from "react";
import { Terminal } from "@wterm/react";
import { WebSocketTransport } from "@wterm/core";
import { WTerm } from "@wterm/dom";
import { GhosttyCore } from "@wterm/ghostty";
import "@wterm/react/css";

const WS_URL = "ws://localhost:3001/api/terminal";

function App() {
  const [core, setCore] = useState<any>(null);
  const transportRef = useRef<WebSocketTransport | null>(null);

  useEffect(() => {
    GhosttyCore.load({ wasmPath: "/ghostty-vt.wasm" }).then(setCore);
  }, []);

  const handleReady = (wt: WTerm) => {
    const transport = new WebSocketTransport({ url: WS_URL, reconnect: true });
    transportRef.current = transport;
    transport.onData((data) => wt.write(data));
    transport.onOpen(() => transport.send(`\x1b[RESIZE:${wt.cols};${wt.rows}]`));
    transport.connect();
  };

  const handleData = (data: string) => transportRef.current?.send(data);
  const handleResize = (cols: number, rows: number) => {
    transportRef.current?.send(`\x1b[RESIZE:${cols};${rows}]`);
  };

  if (!core) return <div>Loading terminal core...</div>;

  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      <Terminal
        core={core}
        cols={80}
        rows={24}
        autoResize={true}
        onReady={handleReady}
        onData={handleData}
        onResize={handleResize}
      />
    </div>
  );
}

export default App;

The copy-wasm script runs before Vite, placing the WASM file in public/ where Vite's dev server can serve it. The wasmPath prop tells Ghostty where to fetch it at runtime.

Final thoughts

W-Term's DOM-based rendering is a meaningful architectural improvement over canvas-based terminals for most web applications. Native text selection, find, and accessibility work without custom implementations. The dirty-row tracking keeps performance practical even for high-frequency output.

The choice between the Zig default core and the Ghostty core is a bundle size versus emulation completeness tradeoff. For most use cases, the 12KB default is sufficient. For cloud IDEs or developer tools where users will run complex TUI applications, the Ghostty core is the better choice.

The WebSocket + node-pty server pattern shown here is straightforward to extend: add authentication, session persistence, container-scoped shells, or multiplexing by building on top of the same bidirectional channel.

Source code and documentation are at github.com/vercel/w-term.

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.