Deploying Static-First
← Part 9
Testing
Backend engineers default to deploying containers behind load balancers. That is the right model for APIs. For frontend assets, it is an order of magnitude slower and more expensive than the correct default: static files on a CDN, as close to the user as possible.
The static-first philosophy is not about building static sites. It is about understanding which parts of your output can be computed at build time, which must be personalized at request time, and pushing as much as possible toward the build-time end of that spectrum.
The Spectrum of Rendering Strategies
Most applications need a mix. The mistake is defaulting to SSR for everything — it trades CDN distribution for server compute on every request.
Static Export: Maximum Distribution
A fully static export produces HTML, CSS, and JavaScript files that require no server runtime. They can be deployed to any CDN or object storage bucket.
# Next.js static export
# next.config.ts
export default {
output: "export",
trailingSlash: true,
};
# Build produces: out/
# Deploy: aws s3 sync out/ s3://your-bucket --delete
# or: vercel --prod (auto-detects static export)The output directory is a file tree that maps 1:1 to URL paths. Deploy it anywhere that serves files over HTTP — S3 + CloudFront, Cloudflare Pages, Netlify, Vercel.
For pages with dynamic segments (like /posts/[slug]), you provide the list of paths at build time:
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch("https://cms.example.com/api/posts").then(r => r.json());
return posts.map((post: Post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.example.com/api/posts/${params.slug}`).then(r => r.json());
return <Article post={post} />;
}This is analogous to pre-computing a lookup table at startup — expensive once, free forever.
CDN Cache Headers: Your Scaling Lever
Static files should be served with aggressive caching. Dynamic, personalized responses should not be cached at the CDN edge. Getting this wrong is the difference between your CDN absorbing 99% of traffic and it absorbing 0%.
# Static assets with content hashes (e.g., _next/static/chunks/abc123.js)
# Never changes, cache forever
Cache-Control: public, max-age=31536000, immutable
# HTML pages (may change on redeploy)
# Cache briefly, revalidate in background
Cache-Control: public, max-age=60, stale-while-revalidate=3600
# API responses: user-specific — never cache at CDN
Cache-Control: private, no-store
# API responses: shared, cacheable
Cache-Control: public, max-age=300, stale-while-revalidate=60, s-maxage=600The s-maxage directive is interpreted by CDN/proxy caches only — it overrides max-age for shared caches while leaving browser cache behavior controlled by max-age.
Setting headers in Next.js:
// next.config.ts
export default {
async headers() {
return [
{
source: "/_next/static/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
{
source: "/api/:path*",
headers: [
{
key: "Cache-Control",
value: "private, no-store",
},
],
},
];
},
};Incremental Static Regeneration (ISR)
ISR is the bridge between static and dynamic. Pages are generated statically but automatically revalidated in the background after a configurable TTL — analogous to stale-while-revalidate at the CDN level.
// Next.js App Router: revalidate is per-route
// This page is cached for 60 seconds, then regenerated in the background
export const revalidate = 60; // seconds
export default async function ProductsPage() {
const products = await fetch("https://api.example.com/products", {
next: { revalidate: 60 }, // also controls fetch-level caching
}).then(r => r.json());
return <ProductGrid products={products} />;
}
// On-demand revalidation from a webhook (CMS publishes new content)
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
}
const { path } = await req.json();
revalidatePath(path);
return NextResponse.json({ revalidated: true });
}Edge Functions: SSR Near the User
When you need server rendering (personalization, auth-gated content, real-time data), edge functions run your server-side logic at CDN PoPs globally — 20–50ms from the user rather than 100–300ms from a single datacenter.
// middleware.ts — runs at the edge on every request
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
export async function middleware(req: NextRequest) {
const token = req.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
}
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET)
);
// Pass user info to the page via headers — no downstream DB lookup needed
const res = NextResponse.next();
res.headers.set("x-user-id", payload.sub as string);
res.headers.set("x-user-role", payload.role as string);
return res;
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/account/:path*"],
};Edge middleware runs on the Vercel/Cloudflare edge network — cold starts are under 5ms because the runtime is V8 isolates, not Node.js containers.
Deployment Pipeline
Automate cache invalidation on deploy — CDN providers have APIs for this. Static assets with content hashes never need invalidation. Only HTML and API responses need purging.
Key Takeaways
- Default to static generation and CDN distribution; add SSR only for routes that require personalization or real-time data — compute at build time where possible.
- Content-hashed static assets deserve
max-age=31536000, immutable; HTML pages should have a shortmax-agewithstale-while-revalidatefor background refresh. - ISR is the operational sweet spot for content that changes periodically — pre-render statically, revalidate on a TTL or webhook trigger, serve from CDN edge.
- Edge functions run your server logic at CDN PoPs globally; use them for auth middleware and personalization to keep SSR latency under 50ms.
- Automate Lighthouse CI in your pipeline with score regression detection — treat a performance regression as a failing test.
- The deployment model for frontend is the same principle you apply to backend at scale: push computation toward build time, push serving toward the user's geographic location.
← Part 9
Testing