The Frontend: Next.js Static Export on CloudFront
Zero SSR, fully static, served from S3. The constraints are real but the tradeoffs were worth it. Six API contract mismatches discovered at UAT.
The dashboard is a Next.js 14 App Router app in output: 'export' mode — fully static, served from S3, zero server-side rendering. This eliminates a whole class of infrastructure complexity (no Node.js server, no Lambda@Edge) but introduces its own set of hard problems.
The dynamic route problem
Static export pre-renders pages at build time. Dynamic routes like /jobs/[id] need generateStaticParams() to enumerate all IDs. You can't enumerate job IDs at build time — they're created at runtime by users.
The workaround: export exactly one placeholder path /jobs/_/index.html, then use a CloudFront Function to rewrite any /jobs/{real-id}/ to /jobs/_/index.html. Inside the page, useParams() returns '_' — not the real ID. The real ID must be read from window.location.pathname.
const [id] = useState(() =>
typeof window !== 'undefined'
? window.location.pathname.split('/').filter(Boolean)[1] ?? '_'
: '_'
)This is not documented anywhere obvious. You discover it at 2 AM when the job detail page shows the wrong job for every user.
CloudFront SPA routing architecture
Two CloudFront Functions in JavaScript:
- api-path-rewrite — on the
/api/*behavior, strips the/apiprefix before forwarding to the ALB, because the FastAPI app is mounted at/not/api/. - spa-rewrite — on the default
*behavior, handles three cases: dynamic route rewrites (→ placeholder), directory paths (→ appendindex.html), extensionless paths (→ append/index.html).
Six API contract mismatches at UAT
Every one of these was discovered by actually running the app against the live API:
account.name→ should beaccount.full_name- Job list returns
Job[]not{ items, total } - Register returns
{ id, email }not{ access_token } - Billing history 404s for new accounts (no subscription yet)
useParams()returns'_'not the actual ID- 401 auto-redirect must not fire on
/auth/*paths — 401 is the expected response for wrong credentials, not session expiry
Error normalization
FastAPI returns validation errors as detail: [{loc, msg, type}]. Business logic errors return detail: {code, feature, ...}. A plain string is also valid. All three must be handled before you can safely render anything — rendering an object directly as a JSX child triggers React error #31.
const detail = (err as any)?.data?.detail
const msg = Array.isArray(detail)
? (detail[0]?.msg ?? fallback)
: typeof detail === 'string'
? detail
: detail?.code === 'tier_limit'
? `${detail.feature} requires the ${detail.required_plan} plan.`
: (err as Error).message || fallback