Skip to content

TypeScript

Orientation. This chapter is browser-first. The opening prose, the prescriptive guidance, the pinned-version table, and the pitfalls all assume a React 19 single-page-application target compiled by Vite, consuming Connect-RPC services through Connect-Web 2. The Node.js TypeScript section that appears further down is a deliberately scoped subsection — full-stack Node.js services are out of scope for this guide and belong to the Multi-Language Considerations chapter (11-multi-language.md) when chosen.

TL;DR

  • The browser-first TypeScript stack is **React 19 + Vite + Tailwind v4
  • Radix/shadcn + TanStack Query + Connect-Web 2 + Zod v4 + react-hook-form**. Every guide-conformant frontend MUST use this exact set of primary dependencies as the default; substitutions require an ADR.
  • Routing canonical: react-router v7 Data Mode (createBrowserRouter with loaders, lazy modules, and error boundaries). TanStack Router v1 is the alternative, recommended only when full-stack TypeScript type-safety on route paths and search params is a hard requirement.
  • API surface: Connect-Web 2 clients generated from .proto files via buf generate + @bufbuild/protoc-gen-es. Connect-Web 2's wire format is fully compatible with Connect-RPC v1 Go servers — the major-version disparity is intentional.
  • Data fetching: TanStack Query v5 for cache, retry, mutation, and background revalidation. Form validation: Zod v4 schemas wired into react-hook-form via @hookform/resolvers/zod. Icons: lucide-react (the 0.x → 1.x jump is a major version bump; pin carefully when adopting v1).
  • Node.js TypeScript subsection covers tooling for Node-side TS files (tsx, tsup, vitest) and version policy. Node 25 reaches EOL in June 2026; guide-conformant projects MUST use Node 24 LTS for stability or Node 26 Current for cutting-edge teams.

Why this choice

The browser-first orientation follows from the spec's interview record: the dominant TypeScript footprint in a guide-conformant service is the SPA frontend that consumes the Go backend through Connect-RPC. A parallel Node.js service is uncommon enough that it belongs in a subsection, not a sibling chapter. Adopting orgs MAY add a Node.js service later; when they do, the Multi-Language Considerations chapter (11-multi-language.md) covers the decision tree for picking it.

The choice of React 19 over React 18 follows from upstream support windows: React 18 entered maintenance mode when React 19 stabilized, and new applications scaffolded after 2025 MUST be on React 19 or later to avoid an immediate-upgrade obligation. React 19's compiler-friendly hook semantics, server-component story, and use() API also unlock data-fetching patterns that earlier majors did not support.

The choice of Vite over Webpack / Rollup / Parcel follows from dev-loop ergonomics. Vite's HMR is sub-second on a cold project and remains sub-second as the project grows past a million lines of TS; Webpack's dev-server cost grows roughly linearly with project size and becomes a productivity tax. The Air-gap chapter (09-airgap.md) also benefits from Vite's static-output build: the production bundle is a tree of plain .html, .js, and .css assets that an embedded HTTP server (e.g., embed.FS in Go) MUST be able to serve directly.

The choice of Tailwind v4 (Oxide engine) over CSS-in-JS or vanilla CSS Modules follows from build-time discipline. Tailwind v4's Oxide engine compiles to a deterministic, small CSS file at build time; no runtime style insertion, no FOUC, no SSR hydration mismatch. Combined with Radix UI primitives (unstyled accessible components) and the shadcn pattern of copying primitive sources into the project rather than installing a styled library, the stack stays maintainable across upstream major-version bumps.

The choice of react-router v7 Data Mode as canonical follows from ecosystem momentum and the Data Mode design itself. createBrowserRouter with loaders and lazy: () => import(...) chunk loading produces a route tree whose data dependencies are statically inspectable, whose chunks load in parallel with the loader, and whose error states are expressible as errorElement boundaries. The TanStack Router v1 alternative is honest, not adversarial: it provides genuinely better type safety on route paths and search params, at the cost of a steeper learning curve and a smaller ecosystem.

The choice of Connect-Web 2 follows from RPC ergonomics. Connect-RPC is wire-compatible with gRPC-Web but trivially debuggable from a browser DevTools network panel (JSON over HTTP/1.1 by default; binary over HTTP/2 when negotiated). The Go-side library is Connect-RPC v1; the TypeScript-side library is Connect-Web v2. The version disparity is intentional — the libraries are wire-compatible but ship their own release trains.

Prescriptive guidance

React + JSX conventions

  • React applications MUST be on React 19 (or later) as of 2026-05-08 scaffolding. Applications on React 18 or earlier MUST plan an upgrade in the next quarterly review.
  • Components MUST be written as function components. Class components MUST NOT be introduced in new code; legacy class components MAY be migrated to function components opportunistically.
  • The default JSX runtime MUST be automatic (handled by Vite's React plugin). Manual import React from "react" at the top of every component file MUST NOT be required.
  • Component file names MUST match the component name in PascalCase (e.g., LoginPage.tsx). Non-component files (hooks, helpers, types) MUST use camelCase or kebab-case consistently within a project.
  • A component MUST export at most one default export and SHOULD prefer named exports for testability and tree-shaking clarity. The lazy route loader pattern (see Routing below) MUST use named exports for page components.

Vite configuration

  • The Vite dev server MUST listen on 0.0.0.0 to be reachable from containers and from devices on the local network. The production build target is static assets, not a long-running Vite process.
  • The Vite dev server MUST proxy /api to the local backend (typically http://localhost:8080) so the frontend uses relative URLs in both dev and production. Hardcoded http://localhost:8080 fetches in component code MUST be rejected on review.
  • The Vite dev server MUST proxy /ws similarly for WebSocket connections. Streaming Connect-RPC and live-update channels rely on this proxy.
  • Vite's define plugin SHOULD inject build-time stamps (VERSION, COMMIT, BUILD_DATE, NODE_VERSION) into import.meta.env so the running frontend can display its own provenance. The Observability chapter (05-observability.md) covers the matching server-side build-info pattern.
  • The resolve.alias map MUST define @ → the project's src/ directory. Imports MUST use @/... paths, not deep relative paths (../../../components/foo). The alias survives file moves; relative paths do not.

Styling: Tailwind v4 + Radix + shadcn

  • Styling MUST be done with Tailwind v4 utility classes. CSS-in-JS libraries (styled-components, Emotion) MUST NOT be introduced in new code; legacy CSS-in-JS code MAY be migrated opportunistically.
  • The Tailwind config MUST live at tailwind.config.ts (TypeScript, not JavaScript) so theme tokens are type-checked. The Oxide engine (Tailwind v4) is the default; the v3 JIT engine MUST NOT be re-introduced.
  • Primitive components (Dialog, Dropdown, Select, Tabs, Tooltip) MUST use Radix UI primitives under the hood. Hand-rolled accessible primitives MUST NOT be introduced in new code.
  • The shadcn pattern SHOULD be used to layer styled components on top of Radix primitives. Under this pattern, primitive source files are copied into the project (typically under src/components/ui/) rather than installed as a styled npm package. This keeps the styling layer adopter-modifiable without a fork.
  • A cn helper (using clsx + tailwind-merge) MUST be used to compose conditional class strings. Direct string concatenation of Tailwind classes MUST NOT be used; conditional logic without cn produces unmergeable utility conflicts.

Routing canonical: react-router v7 Data Mode

  • New applications MUST use react-router v7 Data Mode, configured via createBrowserRouter with a routes array. Routes MUST be declared declaratively (an array of route objects), not via JSX <Routes>/<Route> (which is the legacy "framework mode" usage pattern incompatible with Data Mode's loader behaviour).
  • Each route MUST declare a Component (or element) for synchronous routes, or a lazy: () => import("...") factory for code-split routes. The lazy factory MUST return { Component, loader?, ... } so the chunk and the loader load in parallel.
  • Route loaders MUST throw a redirect(...) response (not call useNavigate) to redirect from the loader. Effect-based redirects cause a flash of the protected page before the redirect fires.
  • A root-route loader SHOULD own session lifecycle (token refresh, user hydration). Children MUST consume the result via useRouteLoaderData("root") rather than re-fetching the session.
  • Error boundaries MUST be declared via errorElement on the route closest to the failure surface. The default behavior (uncaught errors crash to the top-level boundary) MUST NOT be relied on in production.
  • The Surface chapter (03-surface.md) covers the routing position decision (canonical vs alternative) in its own dedicated section and MUST be consulted alongside this chapter for the full rationale.

Routing alternative: TanStack Router v1

  • TanStack Router v1 is an alternative, not the default. Adopting orgs MAY choose TanStack Router when full-stack TypeScript type safety on routes and search params is a hard requirement (typically a complex SaaS dashboard where search-param-driven state crosses many components).
  • An adopting org's choice of TanStack Router MUST be recorded in an ADR with the trade-off articulated: stronger types vs steeper learning curve, smaller ecosystem, fewer third-party integrations.
  • TanStack Router and react-router MUST NOT both be present in the same application. Mixed routing produces duplicate history listeners and competing nav implementations.
  • TanStack Query (data fetching) and TanStack Router (routing) are separate packages from the same maintainer; using TanStack Query does not imply using TanStack Router. Most guide-conformant applications use TanStack Query + react-router.

Forward-looking: react-router v8 (June 2026)

  • The Surface chapter (03-surface.md) flags the imminent react-router v8 release (approximately June 2026). New projects SHOULD monitor for the v8 release and plan an upgrade pass in the first quarterly review after v8 stabilizes.
  • React-router v8's planned breaking changes MAY require route-tree rewrites; the migration MUST NOT be deferred past two quarterly reviews without an ADR documenting why.

Data fetching: TanStack Query v5 + Connect-Web 2

  • Server data MUST be fetched through TanStack Query v5. Direct fetch(...) calls in component bodies MUST NOT be used; they bypass cache, retry, dedup, and revalidation logic that production frontends rely on.
  • RPC calls MUST go through a Connect-Web 2 client constructed once per application from createConnectTransport and createClient. The client MAY be wrapped in a small lib/connect.ts module that adds an auth interceptor for bearer-token injection.
  • Connect-Web's transport MAY be either createConnectTransport (JSON over HTTP/1.1 by default) or createGrpcWebTransport (binary over HTTP/2). New projects SHOULD start with createConnectTransport for debuggability and switch to createGrpcWebTransport only when latency or bandwidth measurements show a benefit.
  • Bearer-token refresh MUST be deduplicated across concurrent failing requests. A single in-flight refresh Promise<boolean> MUST be reused by all callers; multiple parallel refresh attempts are a recurring defect that the deduplication pattern prevents.
  • Token storage SHOULD migrate to httpOnly cookies long-term. Where cookies are not yet wired, localStorage is acceptable with the understanding that XSS exfiltration risk is higher; the Security chapter (06-security.md) covers the rotation pattern.
  • TanStack Query keys MUST follow a consistent shape: ["domain", "operation", ...inputs]. Inconsistent key shapes defeat the cache. Inputs MUST be JSON-serializable; non-serializable inputs (functions, class instances) MUST NOT appear in keys.
  • Mutations MUST invalidate the matching query keys after success via queryClient.invalidateQueries({ queryKey: [...] }). Manual cache writes (setQueryData) MAY be used for optimistic updates but MUST still trigger an invalidation to reconcile with the server.

Forms: Zod v4 + react-hook-form

  • Form schemas MUST be defined with Zod v4. Validation MUST happen through @hookform/resolvers/zod's zodResolver; hand-rolled validation inside register callbacks MUST NOT be used for new forms.
  • The Zod schema MUST be the single source of truth for the form's TypeScript types. z.infer<typeof Schema> MUST be used to derive FieldValues rather than redeclaring the type manually.
  • Form errors MUST surface through react-hook-form's formState.errors shape, rendered by a small <FormMessage> component near the input. Toast notifications MAY be used for submission-level errors (network failures, server validation rejections) but MUST NOT replace inline field-level error rendering.
  • Submit handlers MUST be the handleSubmit(...) wrapper from react-hook-form; bare onSubmit callbacks bypass validation entirely.
  • Zod schemas MAY be shared between the form and any non-form validation pathway (parsing URL search params, validating Connect-RPC inputs). Cross-package shared schemas MUST live in src/lib/schemas/ so the share boundary is explicit.

Icons: lucide-react

  • Icons MUST come from lucide-react. Adopting orgs MAY ship custom SVG icons in src/components/icons/ for brand assets, but the default icon library is lucide-react.
  • The lucide-react package made a major version bump from 0.x to 1.x in early 2026. Projects pinned to 0.x MUST plan a v1 upgrade in the next quarterly review; the API shape changed (named exports per icon, smaller bundle size).
  • Icons MUST be imported by name (import { ChevronDown } from "lucide-react"). Default-export imports MUST NOT be used; they defeat tree-shaking and inflate bundle size.

Build and dev-loop ergonomics

  • The dev loop MUST start with a single command (e.g., pnpm dev, npm run dev, or an orchestrator script). A multi-step manual startup ("first start the backend, then the frontend, then the proxy") MUST be replaced by an orchestrator script that the Infra and Tooling chapter (04-infra-tooling.md) covers.
  • The production build MUST produce static assets (HTML, JS, CSS) consumable by any static file server. Server-side rendering (SSR) MUST NOT be introduced without an ADR; the cost of SSR in build complexity, hosting, and SEO recovery is rarely justified for the guide's target audience.
  • The build MUST type-check with tsc -b before bundling. A bundle produced from un-type-checked code masks defects that the type-checker would catch.
  • Tests MUST be run via vitest (browser tests) and @playwright/test (E2E tests). The Testing chapter (07-testing.md) covers the testing tier discipline.

Node.js (Server-Side TypeScript)

This is a deliberately scoped subsection, not a sibling chapter. Full-stack Node.js services are out of scope for the guide; the Multi-Language Considerations chapter (11-multi-language.md) covers the decision tree for picking a non-Go-non-TS service language.

When to reach for Node.js TypeScript

  • A small CLI tool, build helper, or codegen plugin authored alongside a TS frontend MAY be written in Node.js TS to share the build toolchain. The TS the frontend already uses is sufficient; adding a parallel Go service for a build helper is over-engineering.
  • A Connect-RPC service whose primary purpose is calling third-party TS-ecosystem libraries (e.g., MDX compilation, ESBuild orchestration) MAY be written in Node.js TS. Such a service MUST still respect the Architecture chapter (01-architecture.md) layering discipline.
  • A new general-purpose backend service MUST NOT be written in Node.js TS by default. The default backend language for a guide-conformant service is Go (see all preceding chapters).

Node.js version policy

  • Node 25 reaches EOL in June 2026. New projects MUST NOT scaffold on Node 25. Existing projects on Node 25 MUST plan a migration to Node 24 LTS or Node 26 Current in the next quarterly review.
  • Node 24 LTS is the recommended baseline for teams prioritizing stability. The LTS line has a multi-year support window and the smallest surprise surface.
  • Node 26 Current is the recommended baseline for teams adopting newer language features early. Node 26's --experimental-strip-types removes the need for tsx / ts-node on simple scripts; teams taking this path MUST verify their full dependency tree under Node 26 before adopting.
  • The chosen Node version MUST be pinned in package.json's engines.node field and in .nvmrc (or equivalent) at the repository root. CI MUST run against the pinned Node version.
  • tsx SHOULD be used to execute TS files directly during development (tsx scripts/foo.ts). It is faster than ts-node and has no separate config requirement.
  • tsup SHOULD be used to bundle Node.js TS for distribution. It wraps esbuild with sensible defaults (entry points, externals, dual CJS/ESM output) and produces a small bundle.
  • vitest SHOULD be used for Node-side unit tests. The same vitest configuration SHOULD work for both browser and Node tests; project- level overrides handle the environment differences.
  • pnpm SHOULD be used as the package manager for Node-side projects. npm and yarn MAY be used if an adopting org has a strong in-house preference, but pnpm's content-addressable store is the most disk-efficient default.

Limits of the Node subsection

  • Full-stack Node.js services (the SSR-or-API equivalents of Next.js, Remix, NestJS, Fastify) are out of scope for this guide. Adopting orgs that need such a service MUST write an ADR justifying the language choice; the Multi-Language Considerations chapter (11-multi-language.md) covers the decision tree.

Reference Implementation: Pioneer

The donor codebase implements the browser-first TypeScript stack prescribed above across five files an adopter SHOULD study end to end:

  • /home/ubuntu/pioneer/web/src/App.tsx — the React provider chain. Wraps a QueryClientProvider (TanStack Query) around a ThemeProvider around <RouterProvider router={router} /> (react-router v7 Data Mode). 16 lines, no other concerns mixed in — a faithful demonstration of the layered-provider pattern this chapter prescribes.
  • /home/ubuntu/pioneer/web/src/routes.tsx — the createBrowserRouter route tree. Demonstrates the canonical Data-Mode patterns: a root-route loader that owns token refresh and throws redirect("/login") on failure (the "loader-redirects-not-effect" rule), per-route lazy factories that return { Component } so the chunk loads in parallel with the loader, and RouteGuard / FeatureGate wrappers composed declaratively.
  • /home/ubuntu/pioneer/web/src/lib/connect.ts — the Connect-Web 2 client construction. Demonstrates the auth-interceptor pattern (bearer-token injection on every request), the shared-Promise<boolean> refresh deduplication pattern (a single in-flight refresh re-used by all parallel failing requests), and the "treat 401/403 as terminal but transient 5xx as retryable" classification.
  • /home/ubuntu/pioneer/web/vite.config.ts — the Vite configuration. Demonstrates the /api and /ws proxy pairs that let the frontend use relative URLs in dev, the import.meta.env build-time stamping (version, commit, build date, Node version), and the @ alias to ./src that this chapter prescribes.
  • /home/ubuntu/pioneer/web/package.json — the dependency snapshot. The dependencies block lists every primary framework dependency at the version it was pinned to at the 2026-05-08 snapshot date; the devDependencies block lists the build-toolchain dependencies (TypeScript, Vite, vitest, Playwright). Adopters MAY use these pins as a starting point and update each in their first quarterly review.

Pinned versions

Snapshot date: 2026-05-08. These pins represent the recommended starting versions as of the snapshot date. Quarterly reviews MUST re-confirm each entry against the upstream release line; pins drift in TS ecosystems faster than in Go ecosystems.

Dependency Snapshot pin (2026-05-08) Notes
React ^19.2.5 (current latest 19.2.6) New apps MUST be on React 19+.
Vite ^7.3.2 (current latest 8.0.10) Plan v8 upgrade in next quarterly review.
TypeScript ~5.9.3 (current 6.0 stable; 7.0 beta) Pin to a minor with ~; major upgrades require an ADR.
Tailwind CSS ^4.2.1 (Oxide engine) The v4 Oxide engine is stable in 2026; do not regress to v3 JIT.
@tailwindcss/vite ^4.2.4 Vite plugin matched to Tailwind v4.
@connectrpc/connect ^2.1.1 Connect-Web 2; wire-compatible with Connect-RPC v1 Go server.
@connectrpc/connect-web ^2.1.1 Browser transport.
@bufbuild/protobuf ^2.11.0 Runtime for @bufbuild/protoc-gen-es output.
@bufbuild/protoc-gen-es ^2.11.0 Codegen plugin used by buf generate.
@tanstack/react-query ^5.99.2 Cache, retry, mutation, revalidation.
react-router ^7.14.2 Data Mode canonical; v8 ~June 2026.
TanStack Router v1 alternative Choose only with an ADR justifying the type-safety trade-off.
Zod ^4.3.6 Schema-first validation; v4 is the current major.
react-hook-form ^7.73.1 Form state machine.
@hookform/resolvers ^5.2.2 Connects Zod to react-hook-form.
lucide-react ^0.577.0 (current 1.14.0) Plan v1 upgrade; major version jump 0.x → 1.x.
clsx ^2.1.1 Conditional class-name composition.
tailwind-merge ^3.5.0 Conflict-resolving Tailwind class merge.
Radix UI primitives per-primitive ^1.x / ^2.x shadcn pattern: copy primitives into src/components/ui/.
vitest ^4.1.5 Browser + Node unit tests.
@playwright/test ^1.59.1 E2E tests; see Testing chapter.
Node (LTS) 24 LTS Recommended baseline for stability.
Node (Current) 26 Current Recommended baseline for cutting-edge teams.
Node 25 AVOID — EOL June 2026 New projects MUST NOT scaffold on Node 25.

Pitfalls

  • Scaffolding on Node 25. Node 25 reaches EOL in June 2026. A project that scaffolds on Node 25 inherits an immediate upgrade obligation. Use Node 24 LTS or Node 26 Current.
  • Mixing react-router and TanStack Router. Both packages register history listeners; mixed routing produces nav-event duplication and unpredictable component re-renders. Pick one per application.
  • Bypassing TanStack Query with fetch(...) in components. Direct fetches skip cache, retry, and dedup. The result is request storms on re-renders and stale UI after mutations.
  • Concurrent token refresh. Multiple parallel requests that hit a 401 simultaneously will, without deduplication, fire multiple refresh attempts and consume the refresh token. Use a shared Promise<boolean> that all callers await.
  • Default-export icon imports. import LucideIcons from "lucide-react" defeats tree-shaking and inflates bundles by hundreds of KB. Always use named imports.
  • Effect-driven route redirects. A useEffect that calls navigate("/login") flashes the protected page first. Throw a redirect() response from the loader instead.
  • Hand-rolled form validation. Validation logic that lives inside register callbacks bypasses Zod's type inference and produces drift between the schema and the form. Use zodResolver.
  • SSR for a small SPA. Adding Server-Side Rendering to a small SPA costs build complexity, hosting complexity, and hydration defects, with marginal SEO benefit. SSR MUST NOT be added without an ADR.
  • Pinned Tailwind v3 in a new project. Tailwind v4's Oxide engine is stable; new projects MUST start on v4. Inherited v3 codebases SHOULD plan a v4 migration.

See also

  • The Architecture chapter (01-architecture.md) for the provider-chain layering pattern and the proto-enums-as-source-of-truth discipline that Connect-Web 2 codegen depends on.
  • The Surface chapter (03-surface.md) for the routing position decision in full, including the react-router v8 forward-looking callout and the TanStack Router alternative subsection.
  • The Infra and Tooling chapter (04-infra-tooling.md) for the dev-loop orchestration that drives both backend (air) and frontend (vite) live reload from a single command.
  • The Observability chapter (05-observability.md) for the build-info pattern the frontend's import.meta.env stamping mirrors on the Go side.
  • The Security chapter (06-security.md) for the JWT bearer-token policy and the rotation pattern that the Connect-Web auth interceptor implements.
  • The Testing chapter (07-testing.md) for the vitest + Playwright testing-tier discipline.
  • The Discipline chapter (08-discipline.md) for the project-root principles that govern code style, modularity, and proto-enum boundaries in frontend code.
  • The Air-gap chapter (09-airgap.md) for the embedded HTTP server pattern that serves the Vite static build in disconnected deployments.
  • The Multi-Language Considerations chapter (11-multi-language.md) for the decision tree that scopes when Node.js TypeScript is the right tool versus Python, Bash, or Go.
  • The decisions subdirectory ADR 0006-react-router-v7-canonical.md (when published) covers the canonical-vs-alternative rationale.