← All posts
Engineering·

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 /api prefix 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 (→ append index.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 be account.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