Skip to content

012. Frontend stack: Vite + React + TypeScript, no SSR

  • In the context of adding a browser-based chat UI to rag-sample (Capability #16), where the entire application is a single question-and-answer widget with no multi-page navigation, no SEO requirement, no authenticated server-side rendering, and a target bundle size ≤ 250 KB gzipped,
  • facing the choice of build tooling, rendering model, UI framework, data-fetching layer, and test stack for features #17 and #18,
  • we decided for a static SPA: Vite as the build tool, React with TypeScript as the UI framework, React Query as the data-fetching layer, and no server-side rendering; built artifacts deploy to S3 and are served through CloudFront; the test stack is Vitest + React Testing Library for component tests and Playwright for the E2E happy-path spec,
  • and neglected
    • Next.js (with SSR or hybrid rendering). Next.js would add a server-side runtime — a Lambda, a container, or an Edge function — to a UI whose only dynamic content comes from client-initiated API calls. There is no multi-page navigation to benefit from server rendering, no SEO surface (the app is authenticated and demo-scoped), and no hydration performance problem at this scale. The added infrastructure cost and deployment complexity are not justified.
    • Remix. Same reasoning as Next.js: a server runtime with data loaders and actions is the wrong primitive for a widget that calls one API endpoint from the browser. Remix’s mutation model (form actions) is an awkward fit for a streaming or async chat pattern.
    • Create React App. Deprecated; no active maintenance. Vite is the maintained replacement with faster HMR and a smaller config surface.
    • Vue 3 + Vite or Svelte + Vite. Either would be a defensible tooling choice. React is chosen because it is the operator’s stated stack and because both feature issues (#17, #18) already spec React components, hooks, and React Query by name; switching framework mid-spec introduces unnecessary translation.
    • SWR instead of React Query. Both handle request lifecycle and caching for data-fetching hooks. React Query is chosen because #18 names it explicitly and because its manual query triggering (enabled: false + refetch) is a slightly cleaner fit for a submit-on-demand pattern than SWR’s default revalidation model. The delta is small; either would work.
    • Cypress instead of Playwright. Playwright runs headless Chromium in CI without a proprietary dashboard, has a smaller setup footprint, and is the org’s chosen E2E tool on other projects. Cypress is not rejected on quality grounds; Playwright is simply the lower-friction path in a GitHub Actions environment.
  • to achieve
    • a build and deploy model with no server-side runtime to operate: vite build produces static assets deployed to S3 and cached at CloudFront; the only moving part is the existing API Lambda,
    • a test stack that shares the Vite transform pipeline: Vitest uses the same config and module resolution as the production build, eliminating the Jest ↔ Vite config-bridging problem,
    • a bundle that fits comfortably under the 250 KB gzipped target; React + React Query + TypeScript has well-understood bundle characteristics and tree-shakes cleanly with Vite,
  • accepting
    • no server-side rendering or per-request data pre-fetch; the initial page load is a blank shell until the React app mounts and the user initiates a query,
    • bearer token management in the browser: the API key lives in localStorage (per RAG009 and the threat model); there is no server session to hold it,
    • that adding SSR later would require migrating to Next.js or Remix and introducing a server runtime, which is not a zero-cost change; this ADR would need to be superseded.

The frontend lives under frontend/ in rag-sample-app. The scaffold is a standard npm create vite@latest output: TypeScript template, src/ directory, vite.config.ts. Vitest is configured in vite.config.ts alongside the build config; no separate Jest config exists.

Component structure follows #17: <ChatInput>, <AnswerPanel>, <SourceList>, <KeyBadge>. State is local component state for the active conversation; no global store is introduced for MVP.

The API client (frontend/src/api/queryClient.ts) calls ${VITE_API_URL}/query with an Authorization: Bearer <key> header. VITE_API_URL is the only runtime configuration variable; it is public and injected at build time via Vite’s env-variable mechanism. No secrets appear in the bundle.

The Playwright happy-path spec lives at frontend/e2e/. CI runs it against the QA environment after a successful deploy.

The CloudFront distribution and S3 bucket are provisioned in the infrastructure Terraform module alongside the existing Lambda and Aurora resources. Deployment is covered in the DevOps plan (deployment plan).

vite build emits chunk sizes to stdout. CI fails if any single chunk or the total gzipped bundle exceeds the 250 KB limit specified in #17. The check is a post-build script in package.json; no separate Webpack Bundle Analyzer or bundlesize config is added until the limit is actually in danger.

If a future requirement introduces public-facing pages with SEO constraints, or if authenticated deep-link sharing requires server-rendered metadata, migrate to Next.js and supersede this ADR. The migration path is: move frontend/ into a Next.js scaffold, add a Lambda or container for the SSR runtime, and update the CloudFront distribution to route requests through the server rather than serving static assets directly. The API Lambda is unaffected.