← Back to Blog
TypeScript2026-05-15Β·72 min read

I built "Next.js for the terminal" in TypeScript β€” here's the architecture

By OmarMusayev

Architecting Responsive Terminal Interfaces with File-Based Routing and Integrated SSH

Current Situation Analysis

Building interactive terminal applications in JavaScript and TypeScript has historically required developers to manually orchestrate coordinate systems, manage escape sequences, and stitch together fragmented libraries for navigation and state. The terminal ecosystem remains heavily skewed toward systems languages: Go frameworks like Bubble Tea and Rust libraries like Ratatui dominate the space because they offer mature rendering loops and layout primitives. For web teams, adopting these tools introduces a new language runtime, a separate build chain, and a steep learning curve around terminal control sequences.

Even within the JavaScript ecosystem, most TUI (Terminal User Interface) libraries treat the terminal as a static canvas. Developers are forced to calculate absolute positions, handle window resizes manually, and implement their own routing logic. Remote access typically requires external SSH configuration, reverse proxies, or containerized deployments, adding operational overhead that contradicts the simplicity of a CLI tool. This gap persists because terminal interfaces are frequently categorized as secondary utilities rather than first-class interactive applications.

The landscape is shifting. Modern TypeScript TUI frameworks have reached production maturity, demonstrating that file-based routing, responsive layout engines, and secure remote hosting can be unified under a single npm package. Recent releases like terminaltui v1.8.1, backed by over 2,100 passing tests, prove that TypeScript can handle complex terminal rendering, spatial navigation, and multi-session isolation without sacrificing developer velocity. The core innovation lies in treating the terminal as a responsive viewport rather than a fixed grid, and abstracting SSH deployment into a single command.

WOW Moment: Key Findings

The architectural leap in modern TypeScript TUI frameworks becomes clear when comparing traditional CLI development against the integrated routing-layout-hosting model. The following table contrasts three common approaches across development velocity, layout adaptability, and deployment complexity.

Approach Layout Responsiveness Routing Model Remote Deployment Overhead Language/Toolchain Friction
Traditional CLI Libraries (e.g., inquirer, prompts) None (static prompts) Manual state machines High (requires external SSH/daemon) Low (JS/TS native)
Go/Rust TUI Frameworks (e.g., bubbletea, ratatui) High (custom layout engines) Manual route/state management Medium (requires binary distribution + SSH config) High (new language, separate build pipeline)
Integrated TS TUI Architecture High (12-col grid + breakpoints) File-based + dynamic segments Low (built-in ssh2 server + PTY allocation) Low (npm package, esbuild/tsx, existing CI/CD)

This comparison reveals a critical insight: modern TypeScript TUI frameworks eliminate the trade-off between developer experience and terminal capability. By embedding a responsive layout engine with breakpoints at 60, 90, and 120 terminal columns, and pairing it with automatic spatial navigation, developers no longer need to manually calculate coordinates or manage focus states. The integration of ssh2 with Node's AsyncLocalStorage for session isolation further reduces deployment complexity, allowing teams to ship interactive terminal applications over SSH without touching system configuration files. This architecture enables web teams to build terminal interfaces with the same mental model used for modern web frameworks, while preserving the performance and security expectations of production systems.

Core Solution

Building a production-ready terminal application with this architecture requires three coordinated systems: a file-based router, a responsive layout engine, and an isolated SSH server. Below is a step-by-step implementation using a fresh codebase structure.

Step 1: File-Based Routing with Spatial Navigation

The router scans a designated directory at boot, compiles TypeScript modules via esbuild (or tsx in development), and constructs a route table. Dynamic segments use bracket notation, and spatial navigation automatically calculates the nearest focusable element based on terminal coordinates.

// routes/dashboard.ts
import { createPage, TextBlock, TableBlock } from "terminal-framework";

export const pageConfig = {
  title: "System Overview",
  priority: 1,
  icon: "⚑",
};

export default createPage(({ params }) => {
  const metrics = [
    { service: "API Gateway", status: "healthy", latency: "12ms" },
    { service: "Auth Service", status: "degraded", latency: "145ms" },
  ];

  return [
    TextBlock({ content: `Environment: ${process.env.NODE_ENV || "dev"}` }),
    TableBlock({
      headers: ["Service", "Status", "Latency"],
      rows: metrics.map(m => [m.service, m.status, m.latency]),
      focusable: true,
    }),
  ];
});

Directory structure maps directly to routes:

routes/
  index.ts          β†’ /
  dashboard.ts      β†’ /dashboard
  services/
    index.ts        β†’ /services
    [name].ts       β†’ /services/:name

Architecture Rationale: Spatial navigation replaces tabIndex by calculating Euclidean distance between focusable blocks. This eliminates manual focus management and ensures consistent keyboard navigation across different terminal sizes. The router compiles modules on-demand using esbuild for production speed and falls back to tsx for hot module reloading during development.

Step 2: Responsive Layout Engine

The layout system uses a 12-column grid with flexbox-like behavior. Breakpoints trigger at 60, 90, and 120 terminal columns, automatically reflowing content. Character width calculation uses stringWidth() to properly handle CJK characters and emoji, preventing misalignment.

import { Grid, Row, Cell, Breakpoint } from "terminal-framework";

const layout = Grid({
  columns: 12,
  breakpoints: {
    [Breakpoint.Small]: 60,
    [Breakpoint.Medium]: 90,
    [Breakpoint.Large]: 120,
  },
});

export const DashboardLayout = layout.define([
  Row({
    cells: [
      Cell({ span: 3, collapseAt: Breakpoint.Small, content: SidebarNav() }),
      Cell({ span: 9, collapseAt: Breakpoint.Small, content: MainContent() }),
    ],
    gap: 1,
  }),
]);

Architecture Rationale: Traditional TUIs rely on absolute positioning, which breaks when terminal windows resize or when users connect from different clients. A 12-column grid with explicit breakpoints ensures consistent rendering across 200-column Kitty terminals and 60-column Apple Terminal windows. The stringWidth() utility prevents layout corruption from multi-byte characters, a common failure point in naive TUI implementations.

Step 3: Integrated SSH Server with Session Isolation

The framework ships with a built-in SSH server powered by ssh2. Each connection allocates a PTY, auto-detects TERM and color depth, and runs in an isolated runtime context using AsyncLocalStorage.

import { createTerminalServer } from "terminal-framework/ssh";
import { AsyncLocalStorage } from "async_hooks";

const sessionStore = new AsyncLocalStorage<SessionContext>();

const server = createTerminalServer({
  port: 2222,
  onConnection: (pty, clientInfo) => {
    const context: SessionContext = {
      terminal: pty,
      colorDepth: clientInfo.colorDepth,
      termType: clientInfo.termType,
    };
    
    return sessionStore.run(context, () => {
      return initializeAppRouter();
    });
  },
});

server.start().then(() => {
  console.log("SSH terminal server listening on port 2222");
});

Architecture Rationale: Using AsyncLocalStorage guarantees that concurrent SSH sessions never share state, eliminating race conditions in global variables or cached configurations. The server auto-negotiates color capabilities, falling back to 256-color escape sequences when truecolor is unsupported. This approach removes the need for external SSH daemons or reverse proxies, allowing teams to distribute interactive terminal applications as standalone npm packages.

Pitfall Guide

  1. Ignoring Terminal Resize Events

    • Explanation: Terminals change dimensions frequently. Hardcoding layout calculations causes overflow or truncated content.
    • Fix: Subscribe to SIGWINCH or terminal resize events. Recalculate the grid layout and re-render only affected cells instead of full-screen redraws.
  2. Miscalculating Character Width (CJK/Emoji)

    • Explanation: Standard String.length counts code units, not visual columns. CJK and emoji characters occupy 2+ columns, breaking alignment.
    • Fix: Use a dedicated stringWidth() utility that references Unicode East Asian Width properties. Validate all dynamic text before injecting into grid cells.
  3. Blocking the Event Loop with Synchronous Rendering

    • Explanation: Heavy layout calculations or network calls on the main thread freeze the terminal UI, causing input lag.
    • Fix: Offload layout computation to worker threads or use setImmediate/queueMicrotask to yield control. Debounce rapid resize events to prevent render thrashing.
  4. Session State Leakage in Multi-User SSH

    • Explanation: Global variables or module-level caches persist across SSH connections, causing data cross-contamination.
    • Fix: Wrap each session in AsyncLocalStorage. Never store request-specific data in module scope. Use context-aware dependency injection for database connections and caches.
  5. Overlooking Color Depth Fallbacks

    • Explanation: Truecolor escape sequences (\x1b[38;2;r;g;b) fail on older terminals, resulting in garbled output or missing colors.
    • Fix: Detect COLORTERM and TERM capabilities at connection time. Implement a 256-color fallback palette. Test rendering in Apple Terminal, PuTTY, and legacy SSH clients.
  6. Hardcoding Navigation Paths

    • Explanation: Manual routing logic breaks when directory structures change or dynamic segments are added.
    • Fix: Rely on file-based route generation. Use path-to-regexp or equivalent for dynamic segment parsing. Validate route parameters at build time with TypeScript generics.
  7. Neglecting PTY Configuration

    • Explanation: SSH connections without proper PTY allocation lack terminal capabilities like resize signaling and proper line buffering.
    • Fix: Always request a PTY during SSH handshake. Configure terminal dimensions, echo settings, and signal forwarding. Validate PTY readiness before mounting the UI.

Production Bundle

Action Checklist

  • Verify route table generation: Ensure all routes/ files export a default component and optional config. Validate dynamic segment types.
  • Implement resize handling: Attach SIGWINCH listeners and debounce layout recalculations to prevent render thrashing.
  • Configure color depth detection: Test truecolor vs 256-color fallbacks across multiple terminal emulators before deployment.
  • Isolate SSH sessions: Wrap all connection handlers in AsyncLocalStorage to prevent state leakage between concurrent users.
  • Validate character widths: Replace all String.length calls with stringWidth() for layout calculations involving user-generated or internationalized text.
  • Optimize render cycles: Use dirty-checking or virtual DOM diffing for terminal output to minimize escape sequence volume.
  • Test PTY allocation: Confirm SSH connections request PTYs with correct dimensions and signal forwarding enabled.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Internal team demos Local serve command with SSH tunneling Zero infrastructure setup, instant remote access $0 (no VPS required)
Public-facing terminal apps Dedicated VPS + firewall rules + serve Controlled access, scalable concurrent sessions Low ($5-10/mo VPS)
CI/CD integration testing Headless terminal runner + snapshot testing Validates layout and routing without interactive input Low (CI compute time)
High-concurrency production Load-balanced SSH proxies + session persistence Distributes PTY allocation, prevents single-point failure Medium (infrastructure + monitoring)

Configuration Template

// terminal.config.ts
import { defineConfig } from "terminal-framework/config";

export default defineConfig({
  routes: {
    directory: "./routes",
    dynamicSegmentPattern: /\[(\w+)\]/,
    spatialNav: {
      enabled: true,
      wrapAround: true,
    },
  },
  layout: {
    gridColumns: 12,
    breakpoints: {
      small: 60,
      medium: 90,
      large: 120,
    },
    characterWidth: "unicode", // Uses stringWidth() internally
  },
  ssh: {
    port: 2222,
    host: "0.0.0.0",
    pty: {
      cols: 80,
      rows: 24,
      term: "xterm-256color",
    },
    sessionIsolation: "async-local-storage",
    colorFallback: "256",
  },
  build: {
    compiler: "esbuild",
    target: "node18",
    sourcemap: false,
  },
});

Quick Start Guide

  1. Scaffold the project: Run npx create-terminal-app my-tui to generate a directory with terminal.config.ts, routes/, and src/.
  2. Define your first route: Create routes/index.ts exporting a default component and pageConfig. Use Grid and Cell to structure the layout.
  3. Start the development server: Execute npx terminaltui dev. The router compiles routes via tsx, enables spatial navigation, and watches for file changes.
  4. Test SSH connectivity: Run npx terminaltui serve --port 2222 in a separate terminal. Connect via ssh user@localhost -p 2222 to verify PTY allocation, color detection, and session isolation.
  5. Validate responsiveness: Resize your terminal window to trigger breakpoints at 60, 90, and 120 columns. Confirm layout reflows without coordinate miscalculations or escape sequence corruption.