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 (
createBrowserRouterwith 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
.protofiles viabuf 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 (the0.x → 1.xjump 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). Manualimport 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 usecamelCaseorkebab-caseconsistently 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.0to 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
/apito the local backend (typicallyhttp://localhost:8080) so the frontend uses relative URLs in both dev and production. Hardcodedhttp://localhost:8080fetches in component code MUST be rejected on review. - The Vite dev server MUST proxy
/wssimilarly for WebSocket connections. Streaming Connect-RPC and live-update channels rely on this proxy. - Vite's
defineplugin SHOULD inject build-time stamps (VERSION,COMMIT,BUILD_DATE,NODE_VERSION) intoimport.meta.envso the running frontend can display its own provenance. The Observability chapter (05-observability.md) covers the matching server-side build-info pattern. - The
resolve.aliasmap MUST define@→ the project'ssrc/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
cnhelper (usingclsx+tailwind-merge) MUST be used to compose conditional class strings. Direct string concatenation of Tailwind classes MUST NOT be used; conditional logic withoutcnproduces unmergeable utility conflicts.
Routing canonical: react-router v7 Data Mode¶
- New applications MUST use react-router v7 Data Mode, configured
via
createBrowserRouterwith 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(orelement) for synchronous routes, or alazy: () => 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 calluseNavigate) 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
errorElementon 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
createConnectTransportandcreateClient. The client MAY be wrapped in a smalllib/connect.tsmodule that adds an auth interceptor for bearer-token injection. - Connect-Web's transport MAY be either
createConnectTransport(JSON over HTTP/1.1 by default) orcreateGrpcWebTransport(binary over HTTP/2). New projects SHOULD start withcreateConnectTransportfor debuggability and switch tocreateGrpcWebTransportonly 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
httpOnlycookies long-term. Where cookies are not yet wired,localStorageis 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'szodResolver; hand-rolled validation insideregistercallbacks 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 deriveFieldValuesrather than redeclaring the type manually. - Form errors MUST surface through react-hook-form's
formState.errorsshape, 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; bareonSubmitcallbacks 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-reactpackage made a major version bump from0.xto1.xin early 2026. Projects pinned to0.xMUST 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 -bbefore 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-typesremoves the need fortsx/ts-nodeon 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'sengines.nodefield and in.nvmrc(or equivalent) at the repository root. CI MUST run against the pinned Node version.
Recommended Node-side tooling¶
- tsx SHOULD be used to execute TS files directly during
development (
tsx scripts/foo.ts). It is faster thants-nodeand 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 aQueryClientProvider(TanStack Query) around aThemeProvideraround<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— thecreateBrowserRouterroute tree. Demonstrates the canonical Data-Mode patterns: a root-route loader that owns token refresh and throwsredirect("/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, andRouteGuard/FeatureGatewrappers 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/apiand/wsproxy pairs that let the frontend use relative URLs in dev, theimport.meta.envbuild-time stamping (version, commit, build date, Node version), and the@alias to./srcthat this chapter prescribes./home/ubuntu/pioneer/web/package.json— the dependency snapshot. Thedependenciesblock lists every primary framework dependency at the version it was pinned to at the 2026-05-08 snapshot date; thedevDependenciesblock 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
useEffectthat callsnavigate("/login")flashes the protected page first. Throw aredirect()response from the loader instead. - Hand-rolled form validation. Validation logic that lives inside
registercallbacks bypasses Zod's type inference and produces drift between the schema and the form. UsezodResolver. - 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'simport.meta.envstamping 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.