Services

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#

ConfigReason
output: 'standalone'Replaced by output: 'export'
cacheHandlerISR cache handler is irrelevant for static export
cacheMaxMemorySize: 0Same

Added to next.config.mjs#

ConfigReason
output: 'export'Emit static HTML to out/
images: { unoptimized: true }Disables the image optimization server; images served at original size

Deleted entirely#

  • All app/api-v2/** routes — forms go offline (acceptable trade-off)
  • revalidate exports on all pages — content is frozen at build time, ISR doesn't apply
  • dynamic = '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.

RouteParams source
blog/categories/[category]Derived from _blog/ frontmatter
blog/tags/[tag]Derived from _blog/ frontmatter
blog/authors/[author]Derived from _blog/ frontmatter
case-studies/[slug]MDX files in _case-studies/
(pages)/alternatives/[slug]MDX files in _alternatives/
(pages)/features/[slug]Feature data source
(pages)/events/[slug]MDX files in _events/
(pages)/services/[slug]Static data in code/data files
runners/[slug]Static data
trainings/[platform]Static data
trainings/[platform]/[slug]Static data

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.tsxdraftMode() → remove, hardcode isDraftMode = 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.ts is deleted with the other API routes
  • Any client component fetching /api-v2/blog-categories at 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#

1
apps/www/out/
2
index.html
3
pricing.html
4
blog/
5
post-slug.html # trailingSlash: false → .html files, not directories
6
_next/
7
static/ # content-hashed JS/CSS chunks
8
404.html

Build 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:

nginx
1
root /srv/www/out;
2
index index.html;
3
4
# Redirect moved from middleware
5
rewrite ^/contact-sales$ /contact-us permanent;
6
7
# Try file, then .html extension, then 404
8
location / {
9
try_files $uri $uri.html $uri/index.html =404;
10
error_page 404 /404.html;
11
}
12
13
# Immutable static assets
14
location /_next/static/ {
15
add_header Cache-Control "public, max-age=31536000, immutable";
16
}
17
18
# HTML pages — allow Cloudflare to cache at edge
19
location ~* \.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/out to /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:

bash
1
mise run build:www # pnpm --filter www build
2
# then copy out/ to server
3
rsync -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