Frontend Story
Frontend Story
Companion to index.md, api-router.md, session-management.md, partner-api.md. Covers what the React SPA actually is, how it’s built, how it talks to APIs, and what’s different about doing this in gov.
Stack
| Concern | Choice | Notes |
|---|---|---|
| Bundler | Vite | Fast HMR, sensible defaults, native ESM |
| Framework | React 18+ | Concurrent features for streaming UX |
| Language | TypeScript (strict) | No any, no ! escape hatches |
| Styling | Tailwind | Utility-first, ships only used classes |
| Components | shadcn/ui | Headless primitives, owned in-repo |
| Router | TanStack Router | Type-safe routes, search-param schemas |
| Server state | TanStack Query | Cache + retry + invalidation for SDK calls |
| Forms | React Hook Form + Zod | Schema-validated, minimal re-renders |
| Client state | Zustand or React Context | Only when needed; default to server state |
| Telemetry | OpenTelemetry web SDK | Spans for nav + fetch; exported via fe-support |
| Testing | Vitest + Playwright | Unit + E2E |
| A11y testing | axe-core in Playwright | Section 508 compliance gate |
What the SPA is
A single Vite-built React app that lives at https://app.example.gov. The entire UI is client-side rendered. There is no SSR, no Next.js, no server-rendered shell. nginx serves index.html and hashed assets; React boots in the browser and takes over.
This is deliberate:
- SSR adds a Node runtime to the path and pulls server-side state management into the frontend service.
- The auth model already requires an ALB OIDC cookie before the SPA can even load — there’s no useful “preloaded HTML” to serve unauthenticated users.
- Gov audit posture is simpler when the frontend container is just nginx serving static assets.
If a future page genuinely needs SSR (SEO, share-card previews of public content), peel that page off as a separate small service. Don’t promote the whole SPA.
Build pipeline
flowchart LR
src[apps/web<br/>React + Vite source] --> build[vite build]
sdk["@platform/api-client<br/>generated"] --> build
build --> dist[dist/<br/>index.html + hashed assets]
dist --> dockerfile[Multi-stage<br/>Dockerfile]
dockerfile --> img[nginx:alpine image<br/>SPA baked in]
img --> ecr[(ECR)]
ecr --> fargate[Fargate service<br/>min 2 tasks<br/>behind ALB]
- SDK is a build input, not a runtime fetch. The TS client is generated from Smithy, published to the internal npm registry, and pinned in
apps/web/package.json. Type-safe API calls compile against it. vite buildemitsdist/with hashed assets and a singleindex.htmlreferencing them.- Dockerfile is multi-stage:
node:alpinebuilder runspnpm install && pnpm build,nginx:alpineruntime copiesdist/into/usr/share/nginx/htmlalong with a customnginx.conf. - Tagging: image tag = git SHA. Promote to
prodby re-tagging. - Deploy: CDK pipeline updates the Fargate service task definition to the new tag. Health checks then rolling replace.
nginx config (the part that matters)
server {
listen 80;
# SPA history fallback — every unknown path returns index.html
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
# Hashed assets are immutable
location ~* "\.(?:js|css|woff2|svg|png|jpg|webp|avif)$" {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=31536000, immutable";
gzip_static on;
brotli_static on;
}
# index.html never cached
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Security headers (more in the section below)
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "..." always; # see CSP section
}
Worth noting: ALB has already validated the OIDC session before any request reaches nginx. The container doesn’t re-auth.
SDK consumption from the SPA
The SPA imports the same SDK partners would use, configured for cookie auth, same-origin:
// apps/web/src/lib/api.ts
import { CommercialClient, UserManagementClient } from '@platform/api-client';
const baseConfig = {
endpoint: '', // same-origin → relative paths
credentials: 'include' as const, // attach ALB session cookie
};
export const commercial = new CommercialClient(baseConfig);
export const users = new UserManagementClient(baseConfig);
Wrap in TanStack Query for cache + retry:
const { data, error } = useQuery({
queryKey: ['order', id],
queryFn: () => commercial.getOrder({ id }),
retry: (failureCount, error) => {
if (error instanceof UnauthorizedException) return false;
if (error instanceof ForbiddenException) return false;
return failureCount < 2;
},
});
Generated SDK errors are typed — the SPA’s catch blocks are exhaustive at compile time.
Auth: what the SPA does and doesn’t do
Doesn’t do:
- Touch tokens. They never enter the JS heap.
- Refresh anything. ALB handles it.
- Track the IdP at all. ALB is the only thing the SPA knows.
- Implement OAuth flows. Logging in = following a redirect.
Does do:
- Send
credentials: 'include'on every fetch so the ALB cookie attaches. - Catch
UnauthorizedExceptionfrom the SDK and trigger re-login (see session-management.md). - Track idle activity and show the warning modal at T-minus-warning.
- Hit
/api/fe-support/heartbeaton Continue or periodic interval. - Coordinate logout — clear app session, then redirect to IdP
end_session_endpoint.
// 401 interceptor — installed once in the app shell
queryClient.getQueryCache().subscribe((event) => {
if (event?.query?.state?.error instanceof UnauthorizedException) {
showSessionExpiredModal({
onContinue: () => window.location.reload(),
});
}
});
Routing + code splitting
- TanStack Router with file-based routes. Each route is a lazy-loaded chunk.
- Authentication is enforced upstream (ALB cookie) — the SPA doesn’t have “auth gates” on routes. It loads only if the user is signed in.
- Authorization gates inside the SPA are UX hints, not security boundaries: hide buttons the user can’t use, but trust the API’s 403 as truth. AVP is the only authoritative authz; SPA-side checks are stale by design.
Telemetry (OpenTelemetry)
@opentelemetry/sdk-trace-web+ auto-instrumentations forfetchand document load.- Spans include
traceparentheader on outbound API calls — backend services continue the trace through ADOT → X-Ray. - Sink endpoint:
POST /api/fe-support/telemetry(batched). The frontend-support service forwards to ADOT. - What we trace: route navigations, API calls (auto), user-initiated actions worth observing (
click_create_order). - What we don’t trace: every render, every state update — noisy.
- PII: scrubbed at the frontend-support service before export. Don’t trust client-side scrubbing alone.
const tracer = trace.getTracer('web');
await tracer.startActiveSpan('user.create_order', async (span) => {
span.setAttribute('tenant_id', tenant.id);
try { return await commercial.createOrder(input); }
finally { span.end(); }
});
Feature flags + config
- SPA boot fetches
/api/fe-support/config→ JSON of flags resolved server-side from AppConfig for the current user. - Flags are evaluated once per session by default. Long-lived sessions can subscribe to refresh.
- Don’t put secrets in flags. Flags drive UI behavior, not server logic.
const flags = await api.feSupport.getConfig();
if (flags['commercial.bulk-orders']) { /* show new UI */ }
CSP and security headers
Gov audits look for these. Set at nginx, validated in Playwright tests.
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline'; # Tailwind requires inline at runtime; tighten if you precompile
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' https://auth.example.gov; # API calls + IdP redirect
frame-ancestors 'none';
base-uri 'self';
form-action 'self' https://auth.example.gov;
upgrade-insecure-requests;
frame-ancestors 'none'matchesX-Frame-Options: DENY. Both for old + new clients.connect-srcallows the IdP for OIDC redirects only.- No third-party CDNs. Self-host everything. Gov auditors flag external
script-srcimmediately. - Headers are part of nginx config; CDK injects environment-specific overrides at deploy.
Accessibility (Section 508 / WCAG 2.1 AA)
Non-negotiable for gov. Build it in, don’t bolt it on.
- shadcn/ui primitives are accessible by default (radix-ui under the hood) — keyboard, ARIA, focus management.
- axe-core in Playwright: every E2E run scans for violations. CI fails on serious/critical.
- Manual review: keyboard-only navigation, screen reader spot checks, color contrast audit.
- Skip-to-content link, semantic landmarks, focus-visible styles,
prefers-reduced-motionsupport. - Forms: every input has a
<label>, validation messages are announced viaaria-live. - Document the conformance level in a VPAT (Voluntary Product Accessibility Template). Required for many gov procurements.
Local development
The biggest pain: prod requires ALB OIDC, but devs don’t want to round-trip Cognito on every hot reload. Three options, pick one:
Option A (recommended): Dev IdP + local proxy.
- Run a small dev Cognito User Pool (or
oidc-server-mockDocker image) with seeded test users. - Vite dev server proxies
/api/*to a localmock-alb(small Express app that addsx-amzn-oidc-datasigned with a dev key) → real backend services in a dev AWS account. - Closest to prod behavior, catches integration bugs early.
Option B: Auth-bypass dev mode.
- A
VITE_DEV_USERenv var injects a fake principal at the SDK layer. - Backend services have a dev-only auth bypass keyed on a header (only enabled in dev environments).
- Faster, lower fidelity. Easy to ship the bypass to prod by mistake — guard rigorously.
Option C: Real Cognito in a dev account.
- Vite proxies through a deployed dev ALB.
- Highest fidelity, slowest iteration (each hot reload calls the network).
- Useful for pre-merge testing, not day-to-day.
Use A for the main dev loop and C for pre-merge.
Testing
- Unit (Vitest): components, hooks, utilities. JSDOM environment. Mock the SDK at the boundary.
- Integration (Vitest + MSW): components against a mocked SDK with realistic response shapes (derived from Smithy types — no schema drift).
- E2E (Playwright): critical flows against the dev environment. Uses real Cognito with seeded test users. Includes axe-core a11y scans.
- Visual regression (optional, Playwright
toHaveScreenshot): on shadcn components customized for the design system. - Smoke tests in prod: after deploy, a tiny Playwright suite hits a synthetic user account. Page + alert on failure.
State management
Start with nothing. Add as needed, in this order:
- TanStack Query for everything from the server. This handles caching, retries, optimistic updates, invalidation. Most app state is server state.
- URL / search params for shareable UI state (filters, current page, sort).
- React Context for theme, current user, feature flags — global readonly stuff.
- Zustand only when a piece of state is genuinely cross-cutting and client-owned (e.g. command palette open/closed, multi-step wizard draft).
Avoid Redux unless there’s a specific reason. The above covers ~95% of needs.
Performance
- Code splitting is automatic per route via TanStack Router + Vite. No manual work.
- Image optimization: AVIF + WebP fallback for raster, SVG for icons (preferably as React components via
vite-plugin-svgr). - Bundle budget: enforce per-route max (e.g. 200 KB compressed). CI fails over budget.
- Lighthouse CI on a representative route per PR. LCP < 2.5s, TBT < 200ms on a 4G connection.
- No edge caching because GovCloud has no CloudFront — keep total payload small to compensate.
What this means for the partner SDK story
partner-api.md covers public partners using the SDK. The frontend uses the same SDK in a different configuration:
| First-party SPA | Partner system | |
|---|---|---|
| Endpoint | app.example.gov (same-origin, relative) | api.example.gov/v1 |
| Auth | ALB session cookie (transparent) | OAuth client credentials → Bearer |
| Token in JS heap? | No | Yes (SDK manages) |
| Error types | Same Smithy-generated classes | Same Smithy-generated classes |
| Versioning | Tracks latest internal version | Pinned to a stable major (e.g. v1) |
One generated client. Two configurations. Zero divergence in error shapes or response types.
Open questions
- shadcn vs. a heavier component library (Material, Ant Design). shadcn gives ownership at the cost of building more in-house. Default to shadcn; switch only if speed-to-market trumps customization.
- Monorepo layout:
apps/web+packages/api-client+packages/uiin pnpm workspaces is standard. Worth setting up day one. - Where do generated SDK types live? Published package (versioned, decoupled) vs. monorepo-local (simpler, tighter coupling). Default to published — the SDK is a product, not an internal helper.
- Service worker for offline? Probably not for a gov internal SaaS; adds complexity and doesn’t match typical access patterns.
- Theming / multi-brand? If the same codebase serves multiple gov agencies with different branding, design tokens (CSS variables) configured per tenant at boot. Don’t pre-optimize if not in scope.