2026 04 01 Www Full Page Cache Design
www Full Page Cache — Design Spec
Date: 2026-04-01
Status: Approved
Goal: Convert apps/www from a Next.js SSR/ISR server to a fully static export so the entire server stack can be shut down and the site served from a CDN/static file server.
1. Architecture#
Output mode#
next.config.mjs switches from output: 'standalone' to output: 'export'.
Next.js pre-renders every page to HTML at build time and writes to apps/www/out/. No Node process is required to serve traffic — Nginx or Cloudflare Pages serves the flat file tree directly.
Removed from next.config.mjs#
Added to next.config.mjs#
Deleted entirely#
- All
app/api-v2/**routes — forms go offline (acceptable trade-off) revalidateexports on all pages — content is frozen at build time, ISR doesn't applydynamic = 'force-dynamic'on changelog — replaced with build-time fetch
Middleware#
middleware.ts runs server-side and does not execute on a static CDN. Its responsibilities move:
/contact-sales → /contact-us(308) → moved to Nginx/Caddy config- First-referrer cookie stamping → dropped (no server to set cookies)
2. Dynamic Routes#
All dynamic [slug]/[param] routes need generateStaticParams and dynamicParams = false. Unknown slugs return 404 instead of triggering SSR.
Special case: opt-out/[ref]#
The [ref] param is not enumerable (unsubscribe tokens per user) and the backend processing it is going offline. The entire (pages)/opt-out/[ref]/ route is replaced with a single static page at (pages)/opt-out/page.tsx showing a fallback message: "To unsubscribe from our emails, contact us at team@assistance.bg".
3. Data Fetching#
Changelog (app/(pages)/changelog/page.tsx)#
- Remove
export const dynamic = 'force-dynamic' - Remove
export const revalidate = 900 - GitHub API fetch runs once at build time; renders the 10 most recent entries
- Pagination (
?next=cursor) is removed — a static page cannot paginate dynamically - "Load more" UI replaced with a link to the GitHub releases page
Careers (app/(pages)/careers/page.tsx)#
- Remove
export const revalidate = 300 - Ashby job board + GitHub contributors fetched once at build time
- Job listings frozen until next build — acceptable for a marketing site
next/headers usage#
next/headers exports (draftMode(), cookies(), headers()) are server-only APIs that throw in static export. The implementation must scan all files in apps/www/app/ for imports from next/headers and remove or stub each one.
Known usage:
app/blog/[slug]/page.tsx—draftMode()→ remove, hardcodeisDraftMode = false
Unknown: a full grep of apps/www for from 'next/headers' is required at implementation time to catch any others.
Draft preview is not supported in static export (requires a running server).
blog-categories API route#
app/api-v2/blog-categories/route.tsis deleted with the other API routes- Any client component fetching
/api-v2/blog-categoriesat runtime is updated to import the category list as a static module (derived at build time, embedded in the JS bundle)
4. Build & Serving#
Build output#
1apps/www/out/2 index.html3 pricing.html4 blog/5 post-slug.html # trailingSlash: false → .html files, not directories6 _next/7 static/ # content-hashed JS/CSS chunks8 404.htmlBuild command: pnpm --filter www build
Build time will increase (pre-rendering 500+ pages including 395 blog posts) but this is a one-time cost per deploy.
Nginx config#
Nginx in the Podman stack replaces the Node standalone container for www.
Key directives:
1root /srv/www/out;2index index.html;34# Redirect moved from middleware5rewrite ^/contact-sales$ /contact-us permanent;67# Try file, then .html extension, then 4048location / {9 try_files $uri $uri.html $uri/index.html =404;10 error_page 404 /404.html;11}1213# Immutable static assets14location /_next/static/ {15 add_header Cache-Control "public, max-age=31536000, immutable";16}1718# HTML pages — allow Cloudflare to cache at edge19location ~* \.html$ {20 add_header Cache-Control "public, max-age=3600";21}Podman compose change#
podman-compose.yml www service changes from Node standalone to Nginx static:
- Image:
nginx:alpine - Volume: mount
apps/www/outto/srv/www/out - Nginx config: mounted from
tools/nginx/www.conf
Redis remains in the compose file (other apps may still use it); www no longer connects to it.
Redeploy workflow#
Since ISR is gone, content updates require a full rebuild:
1mise run build:www # pnpm --filter www build2# then copy out/ to server3rsync -av apps/www/out/ server:/srv/www/out/A CI cron job automating this is the planned follow-up (Option C).
Out of Scope#
- Auth flows (
/auth/callback, GoTrue) — client-side, continue to work as long as GoTrue is accessible - SaaS dashboard (
apps/manager) — not affected by this change - Other apps (
docs,learn, etc.) — not in scope - CI automation (nightly rebuild) — follow-up task