Skip to content

ADR-057: React ecosystem — Vite, TanStack Router, TanStack Query, Tailwind CSS, Radix UI

Status Accepted
Date 2026-05-08
Deciders CTO, Head of Architecture
Affects repos bank-app

Status: Accepted — 2026-05-08 Supersedes: ADR-007 — Frontend framework — React/Next.js with Capacitor

Context

ADR-007 established React + Capacitor as the frontend framework. That decision stands. ADR-007 also named Next.js as the specific build tool and router, and those choices are what this ADR revisits.

ADR-007 already contained an important constraint: "No server-side rendering for the mobile build — static export only." That constraint means Next.js — an SSR-first framework — was always being used in a mode that fights its defaults. When MOD-069 (customer app shell) entered detailed design, the mismatch became structural: Next.js App Router (the current-generation API) is incompatible with static export for several core patterns the app requires (middleware, parallel routes, streaming). Pages Router (the static-export-compatible generation) is in maintenance mode. The practical build target is a pure SPA with Capacitor wrapping — which is precisely what Vite is designed for.

This ADR supersedes ADR-007's framework-specific selections (Next.js, Next.js router) and specifies the full library set: Vite 5, TanStack Router, TanStack Query, Tailwind CSS, and Radix UI. React and Capacitor carry over unchanged.

Options evaluated

The following were evaluated during MOD-069 detailed design. Each section below documents the reasoning.


Decision

1. Vite 5 replaces Next.js as the build tool

Selected: Vite 5

The bank-app builds are:

Build target Vite command Output Distribution
build:web vite build --mode staff dist/ → CDN Staff browser (back office)
build:mobile vite build --mode customer dist/npx cap sync iOS App Store + Google Play

Both targets produce static file bundles. Neither requires a runtime Node.js server.

Why not Next.js:

  • SSR overhead with no SSR benefit. Next.js is fundamentally an SSR framework. Its defaults (React Server Components, route handlers, middleware) do not apply to a Capacitor mobile app. Using output: 'export' in next.config.ts disables these features but still pulls in Next.js's server-side bundling and module resolution overhead.
  • App Router + static export incompatibility. Next.js App Router (v14+) does not support full static export for several patterns the bank-app needs: parallel routes (used for modal overlays), server middleware (used for auth redirect gating in the web target), and streaming responses. The workarounds are non-trivial and introduce divergence between the two build targets.
  • Pages Router maintenance mode. Pages Router remains the only App-Router-free Next.js version with reliable static export. It receives security patches only; new architectural patterns (server actions, RSC) are App Router-only.
  • Capacitor integration complexity. The Next.js static export pipeline requires next build followed by a separate next export step (or output: 'export' + careful handling of next/image, next/link, dynamic route fallbacks), then npx cap sync dist/. Vite produces dist/ directly from vite build. The Capacitor webDir is dist/ in both cases but the Vite path has one fewer build stage and no static-export compatibility surface to maintain.

Why Vite:

  • Purpose-built for static SPA/CSR output — the exact target.
  • Native ESM during development: HMR is near-instant. No Babel transform pipeline unless opted in.
  • Rollup-based production bundle: tree-shaking is more aggressive than Webpack (which backs Next.js); this directly serves NFR-016 (≤ 500kb gzipped customer build).
  • vite build --mode [staff|customer] with .env.staff / .env.customer files provides clean per-surface environment variable injection without separate Next.js rewrites.
  • Capacitor build: vite build && npx cap sync — two commands, no export-mode edge cases.

2. TanStack Router replaces Next.js router

Selected: TanStack Router v1

TanStack Router is a fully type-safe, file-based router for client-side React apps. It has no dependency on Next.js and operates in pure SPA mode.

Options evaluated:

Option Type-safety SSR coupling Capacitor createMemoryHistory Decision
TanStack Router v1 Full inference — route params, search params, loader data None — client-side only Native support, first-class API Selected
React Router v6 (Remix Data API) Partial — useParams returns string \| undefined everywhere Optional but SSR-biased Supported via MemoryRouter Valid alternative
React Router v6 (classic) Partial None MemoryRouter Simpler, less type-safe
Next.js router (Pages Router) Good Hard dependency Not applicable Rejected (tied to Next.js)

Why TanStack Router over React Router v6:

React Router v6 with the Data API (loaders, actions) is a credible choice and worth considering if the team has strong prior familiarity. TanStack Router's differentiating characteristics for this project:

  • Search-param type safety. Banking UIs require structured URL state (filter state, pagination, date ranges in the back office, deep link parameters for payments on mobile). TanStack Router validates and infers search params at the type level — invalid params are a compile error, not a runtime bug.
  • File-based routing with full TS inference. Routes defined in src/routes/ are automatically typed; useParams(), useSearch(), and useLoaderData() return fully typed values without casting.
  • No Remix coupling. React Router v6's Data API mirrors Remix's mental model. There is some tension between the loader/action pattern (designed for MPA-style transitions) and TanStack Query's client-side cache model. TanStack Router + TanStack Query have an explicit integration story (router invalidates query cache on navigation; queries are initiated from route loaders with TanStack Query).

Capacitor deep linking: TanStack Router's createMemoryHistory() is the correct history adapter for Capacitor. URL-based deep links from push notifications are resolved by mapping the incoming URL to a memory history push — documented in the MOD-069 technical design doc.

React Router v6 remains a valid choice if a future module team has strong prior expertise. This ADR does not prohibit it for back-office-only modules where Capacitor deep linking is irrelevant, but TanStack Router is the default for all bank-app routing.

3. TanStack Query v5 for server state

Selected: TanStack Query v5 (formerly React Query)

TanStack Query manages all async server state: API calls, background refresh, cache invalidation, optimistic updates, and request deduplication.

There is no credible alternative in the same class. useSWR (Vercel) is a lighter option appropriate for simple read-only data needs; the bank-app has transactional mutations (payments, profile updates, credit applications) where TanStack Query's mutation lifecycle, rollback, and invalidation model is materially better.

State that is not TanStack Query: - Auth/session state: React Context (AuthContext) — low-change-frequency, synchronous, read by deep component trees. - UI state (modal open/closed, form step): local useState — component-scoped.

No Redux, no Zustand for v1. The combination of TanStack Query (server state) + TanStack Router search params (URL state) + React Context (auth) covers all v1 state requirements without a global client-side store. Zustand is a valid addition if a future feature introduces complex client-side domain state (e.g. a multi-step payment builder with branching logic), but that decision is deferred to the module that introduces the requirement.

4. Tailwind CSS v3 for styling

Selected: Tailwind CSS v3

Utility-first CSS with @tailwindcss/forms, @tailwindcss/container-queries, and class-variance-authority (CVA) for component variant management.

Tailwind's production build purges unused classes — the output CSS for the customer build is typically under 12kb. This is compatible with NFR-016.

The design system token set (colour palette, spacing scale, typography scale, radius, shadow) is encoded in tailwind.config.ts. All components reference tokens via Tailwind utilities — no magic numbers in component files.

CSS Modules and styled-components were evaluated and rejected: both require a context switch between the component file and a separate style definition, and neither has a clear tree-shaking story for the Capacitor mobile bundle.

5. Radix UI primitives for accessible components

Selected: Radix UI Primitives (latest stable)

Radix UI provides unstyled, accessible headless primitives: Dialog, DropdownMenu, Select, Popover, Tooltip, Tabs, Alert Dialog, and others. Each component ships with correct ARIA roles, keyboard navigation, and focus management. Styling is applied entirely via Tailwind utilities and CVA variants.

Options evaluated:

Option Accessibility Styling model Composability Decision
Radix UI Primitives WAI-ARIA compliant — built-in keyboard nav, focus trap, aria-* management Unstyled — Tailwind + CVA High — each primitive is composable Selected
React Aria (Adobe) Excellent — lower-level, more explicit control Render-props + CSS Very high but verbose Valid for accessibility-specialist teams
Headless UI (Tailwind Labs) Good Unstyled — Tailwind-first Moderate Narrower component set; Radix is more complete
shadcn/ui Good (wraps Radix) Pre-styled, copy-paste High Considered as a starting point; bank has a bespoke design system

Why Radix over React Aria:

React Aria is the correct choice when building a complete design system from a clean slate with strong accessibility requirements and a dedicated engineering resource. For this project, the priority is shipping a working app with a consistent, accessible component set — the component library is secondary to the application features. Radix's primitives give correct ARIA behaviour out of the box, where React Aria requires explicit useButton, useDialog, useSelect, etc. wiring. Both are valid; Radix has lower initial implementation overhead.

React Aria remains the preferred choice if the back office surface ever requires WCAG 2.2 Level AAA compliance or complex custom widgets (e.g. a data-grid with virtual scrolling and keyboard selection).

Accessibility gates: - eslint-plugin-jsx-a11y runs in CI — a11y/ rule set, errors fail the build. - @axe-core/playwright runs in the Playwright E2E suite — injects axe into each page and asserts zero violations at WCAG 2.1 AA. - Radix primitives are not a substitute for testing — the axe gate catches cases where Radix primitives are misused (missing aria-label on icon-only buttons, etc.).

6. Playwright for E2E and visual regression

Selected: Playwright with visual snapshot testing

Playwright is the E2E and visual regression testing tool. It replaces any prior suggestion of a SaaS visual testing service.

Visual snapshots are committed to the bank-app repo under tests/visual/__snapshots__/. maxDiffPixelRatio: 0.001 in playwright.config.ts enforces FR-347's requirement that no UI change introduces more than 0.1% pixel difference without an explicit snapshot update. This is not a placeholder — snapshot baseline must be established at first-merge and updated deliberately.


Constraints this creates for bank-app

  1. No SSR at any point. Vite produces a static SPA. If a future surface requires SSR (e.g. a public-facing marketing or onboarding page), that surface requires a separate repo and separate ADR.
  2. Capacitor compatibility must be maintained. Every web design decision — routing history strategy, API call patterns, storage access, file upload — must be reviewed for Capacitor compatibility. Mobile distribution (iOS App Store) is planned for v1.5. Decisions that would require a rewrite for Capacitor are not acceptable.
  3. Router search params are typed. All URL state must be declared in TanStack Router's search params schema. Untyped URLSearchParams access is prohibited in application code.
  4. No global client-side store until required. A Zustand or similar store must be proposed via ADR (or ADR amendment) before introduction.
  5. Tailwind config is the design token source. Do not introduce CSS custom properties, theme files, or CSS-in-JS theme objects alongside Tailwind. One token system.

What carries over from ADR-007

Decision ADR-007 Status
React React Unchanged
Capacitor for native app distribution Capacitor Unchanged
Single codebase, two build targets Two build targets Unchanged (Vite modes replace Next.js targets)
Vue/Nuxt rejected Rejected Unchanged
React Native rejected Rejected Unchanged
No SSR for mobile build Explicit constraint Unchanged — Vite enforces this structurally

What changes from ADR-007

Topic ADR-007 decision This ADR
Build tool Next.js (Pages Router, static export) Vite 5
Router Next.js router TanStack Router v1
Deep link handling "Next.js router and Capacitor deep link handling" TanStack Router createMemoryHistory()
Server state Unspecified TanStack Query v5
Styling Unspecified Tailwind CSS v3 + CVA
Component primitives Unspecified Radix UI Primitives
State management Unspecified Context + TanStack Query; no global store for v1
Accessibility Unspecified Radix + eslint-plugin-jsx-a11y + axe/Playwright
Visual regression Unspecified Playwright snapshots, maxDiffPixelRatio: 0.001

ADR Title Relationship
ADR-004 Single frontend codebase this ADR implements the library layer for ADR-004
ADR-007 Frontend framework — React/Next.js with Capacitor superseded by this ADR
ADR-026 Customer authentication — Cognito, mobile-first, passwordless Cognito custom auth challenge cycle is implemented in src/lib/auth.ts via @aws-sdk/client-cognito-identity-provider; Hosted UI is not used
ADR-034 Web hosting and mobile app distribution deployment targets unchanged; Vite dist/ replaces Next.js export output

All ADRs Compiled 2026-05-22 from source/entities/adrs/ADR-057.yaml