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'innext.config.tsdisables 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 buildfollowed by a separatenext exportstep (oroutput: 'export'+ careful handling ofnext/image,next/link, dynamic route fallbacks), thennpx cap sync dist/. Vite producesdist/directly fromvite build. The CapacitorwebDirisdist/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.customerfiles 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(), anduseLoaderData()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¶
- 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.
- 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.
- Router search params are typed. All URL state must be declared in TanStack Router's search params schema. Untyped
URLSearchParamsaccess is prohibited in application code. - No global client-side store until required. A Zustand or similar store must be proposed via ADR (or ADR amendment) before introduction.
- 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 |
Related decisions¶
| 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