CDN Caching Strategies for Next.js: Speed Up Your Website Globally
Over the last seven posts we’ve inched our way from the basics of static caching to the gnarly edges of full-page SSR caching. Each layer shaved milliseconds off the critical path, but there’s a ceiling you can’t break with origin-side tricks alone. That ceiling is physical distance. A user in Doha still waits on round-trips to Frankfurt or Virginia unless you give them a copy of your content closer to home. That’s where a Content Delivery Network (CDN) becomes the force-multiplier in your Next.js performance playbook. By scattering cached responses across hundreds of edge POPs, a CDN turns latency from a continent-spanning problem into a cross-city hop—often cutting Time-to-First-Byte by 70-90 ms on typical ecommerce payloads. But raw speed isn’t enough. Successful CDN caching means knowing what to cache, how long, and when to re-validate—especially with modern rendering patterns like ISR, streaming SSR, and Edge Functions that live somewhere between “static” and “dynamic.” Misconfigure a header and you’ll serve yesterday’s price to a million shoppers or blow your hit rate with an errant auth cookie. In this chapter we’ll zoom out to the global layer and learn how to: Map each Next.js rendering mode (SSG, ISR, SSR, Edge) to the right CDN policy. Craft Cache-Control directives (s-maxage, stale-while-revalidate, immutable) that both browsers and CDNs respect. Configure provider-specific rules on Vercel CDN, Cloudflare, and AWS CloudFront without locking yourself into a single host. Purge and re-validate content automatically when content editors hit Publish—no 3 a.m. “cache-busting” deploys required. Debug cache hits, misses, and unexplained STALE responses like a pro using headers, DevTools, and edge analytics. By the end you’ll have a battle-tested checklist for global caching that keeps First-Time-Visitors wowed and returning users perpetually up-to-date—all while slashing your origin bill and keeping Lighthouse happy. Let’s bring your app closer to every user on the planet. How CDNs Work with Next.js — A Pragmatic Primer Before we dive into headers and provider dashboards, let’s ground ourselves in the mechanics of why a CDN can even cache your Next.js output. The Life of a Request (30 000 ft View) DNS → Anycast POP A visitor in Singapore asks for www.yoursite.com. DNS hands back an IP that actually belongs to many edge servers. BGP routing steers the TCP connection to the nearest Point-of-Presence (POP). Edge TLS + Layer-7 Logic The CDN terminates TLS, strips off a few microseconds of handshake latency, and checks its local cache store (RAM / SSD). Cache Lookup HIT → Serve bytes immediately. MISS → Forward the request upstream (your Vercel deployment, a custom origin running Next.js, or another tier of CDN). Store & Serve When the origin responds, the edge stores the object keyed by URL plus whatever “vary” dimensions you’ve instructed (cookies, headers, query string). Subsequent users within the same geography now get sub-50 ms TTFB. Rule of Thumb: An object lives in a POP until the least of (a) its s-maxage, (b) LRU eviction from limited space, or (c) an explicit purge. What Next.js Produces (and Where It Lands) Artifact Generated By Default CDN Behaviour (Vercel) Why It Matters /_next/static/* (JS/CSS chunks) next build (immutable) Cached forever (immutable, max-age=31536000) Fingerprinted filenames guarantee uniqueness, so long TTL is safe. Images (/_next/image or remote) next/image loader Cached per request params¹ Width/quality variants create unique URLs, perfect for edge caching. HTML (SSG) next build Cached until next deploy Treated as static by default—one copy per locale/route. HTML (ISR) App Router revalidate Cached for revalidate seconds After TTL, first request triggers background re-render. HTML (SSR / Edge) Request-time rendering Not cached unless you add headers Gives you control to vary by cookie, auth, etc. ¹ Vercel CDN, Cloudflare Images, or CloudFront behave similarly because the loader appends width/quality params—effectively a fingerprint. Where CDNs Slip Up with Frameworks Header Blindness Many beginners rely on browser-focused max-age. CDNs actually obey s-maxage if it exists, otherwise they fall back to max-age. Forget the s- prefix and you’ll wonder why every edge server is a permanent cache-miss. Cookie Pollution A single Set-Cookie: session=abc123 flag on your marketing page tells most CDNs “do not cache, this is personalized.” Solution: set Cache-Control: public and clear any personalization cookies server-side before the CDN sees them. Hidden Query Strings CloudFront treats ?utm_source= as a new cache key by default if Query String Forwarding is enabled. Vercel ignores query strings unless you opt in via Route Segment Config. Know your provider’s defaults or watch your hit ratio tank. Next.js 15 + CDNs in Practice Vercel: Integrates its own ed

Over the last seven posts we’ve inched our way from the basics of static caching to the gnarly edges of full-page SSR caching. Each layer shaved milliseconds off the critical path, but there’s a ceiling you can’t break with origin-side tricks alone. That ceiling is physical distance. A user in Doha still waits on round-trips to Frankfurt or Virginia unless you give them a copy of your content closer to home.
That’s where a Content Delivery Network (CDN) becomes the force-multiplier in your Next.js performance playbook. By scattering cached responses across hundreds of edge POPs, a CDN turns latency from a continent-spanning problem into a cross-city hop—often cutting Time-to-First-Byte by 70-90 ms on typical ecommerce payloads.
But raw speed isn’t enough. Successful CDN caching means knowing what to cache, how long, and when to re-validate—especially with modern rendering patterns like ISR, streaming SSR, and Edge Functions that live somewhere between “static” and “dynamic.” Misconfigure a header and you’ll serve yesterday’s price to a million shoppers or blow your hit rate with an errant auth cookie.
In this chapter we’ll zoom out to the global layer and learn how to:
- Map each Next.js rendering mode (SSG, ISR, SSR, Edge) to the right CDN policy.
- Craft
Cache-Control
directives (s-maxage
,stale-while-revalidate
,immutable
) that both browsers and CDNs respect. - Configure provider-specific rules on Vercel CDN, Cloudflare, and AWS CloudFront without locking yourself into a single host.
- Purge and re-validate content automatically when content editors hit Publish—no 3 a.m. “cache-busting” deploys required.
- Debug cache hits, misses, and unexplained STALE responses like a pro using headers, DevTools, and edge analytics.
By the end you’ll have a battle-tested checklist for global caching that keeps First-Time-Visitors wowed and returning users perpetually up-to-date—all while slashing your origin bill and keeping Lighthouse happy. Let’s bring your app closer to every user on the planet.
How CDNs Work with Next.js — A Pragmatic Primer
Before we dive into headers and provider dashboards, let’s ground ourselves in the mechanics of why a CDN can even cache your Next.js output.
The Life of a Request (30 000 ft View)
-
DNS → Anycast POP
A visitor in Singapore asks for
www.yoursite.com
. DNS hands back an IP that actually belongs to many edge servers. BGP routing steers the TCP connection to the nearest Point-of-Presence (POP). -
Edge TLS + Layer-7 Logic
The CDN terminates TLS, strips off a few microseconds of handshake latency, and checks its local cache store (RAM / SSD).
-
Cache Lookup
HIT → Serve bytes immediately.
MISS → Forward the request upstream (your Vercel deployment, a custom origin running Next.js, or another tier of CDN).
-
Store & Serve
When the origin responds, the edge stores the object keyed by URL plus whatever “vary” dimensions you’ve instructed (cookies, headers, query string). Subsequent users within the same geography now get sub-50 ms TTFB.
Rule of Thumb: An object lives in a POP until the least of (a) its s-maxage, (b) LRU eviction from limited space, or (c) an explicit purge.
What Next.js Produces (and Where It Lands)
Artifact | Generated By | Default CDN Behaviour (Vercel) | Why It Matters |
---|---|---|---|
/_next/static/* (JS/CSS chunks) |
next build (immutable) |
Cached forever (immutable, max-age=31536000 ) |
Fingerprinted filenames guarantee uniqueness, so long TTL is safe. |
Images (/_next/image or remote) |
next/image loader |
Cached per request params¹ | Width/quality variants create unique URLs, perfect for edge caching. |
HTML (SSG) | next build |
Cached until next deploy | Treated as static by default—one copy per locale/route. |
HTML (ISR) | App Router revalidate
|
Cached for revalidate seconds |
After TTL, first request triggers background re-render. |
HTML (SSR / Edge) | Request-time rendering | Not cached unless you add headers | Gives you control to vary by cookie, auth, etc. |
¹ Vercel CDN, Cloudflare Images, or CloudFront behave similarly because the loader appends width/quality params—effectively a fingerprint.
Where CDNs Slip Up with Frameworks
-
Header Blindness
Many beginners rely on browser-focused
max-age
. CDNs actually obeys-maxage
if it exists, otherwise they fall back tomax-age
. Forget thes-
prefix and you’ll wonder why every edge server is a permanent cache-miss. -
Cookie Pollution
A single
Set-Cookie: session=abc123
flag on your marketing page tells most CDNs “do not cache, this is personalized.” Solution: setCache-Control: public
and clear any personalization cookies server-side before the CDN sees them. -
Hidden Query Strings
CloudFront treats
?utm_source=
as a new cache key by default if Query String Forwarding is enabled. Vercel ignores query strings unless you opt in via Route Segment Config. Know your provider’s defaults or watch your hit ratio tank.
Next.js 15 + CDNs in Practice
- Vercel: Integrates its own edge network. Most of the heavy lifting (immutable asset headers, ISR revalidation) “just works” as long as you leave the defaults intact. You only intervene for SSR pages, auth-based variation, or experimental Edge Functions.
-
Self-Hosted (Cloudflare / CloudFront): You’re in charge of headers and origin shielding. A bad header combo on one route can blow away cache for your whole site. Treat every
res.setHeader('Cache-Control', …)
as a production-level contract. - Hybrid Origins: It’s perfectly valid to serve static assets from Vercel, dynamic API from AWS behind CloudFront, and images via Cloudflare—just keep the cache contract consistent so debugging stays sane.
Key takeaway: A CDN is only as smart as the hints you give it. Next.js produces the right artifacts out of the box, but telling the edge how long to keep each one—and when to ignore cookies, query strings, or headers—is the difference between a 90 % hit ratio and a sluggish worldwide experience.
Up next, we’ll map those rendering modes to concrete caching strategies—and show exactly which Cache-Control
spell to cast for each.
Caching Strategy per Rendering Method
Not every page deserves the same shelf life—nor the same place on that shelf. A marketing homepage can chill in the edge cache for a year, while a stock-price widget should barely unpack its bags. The trick is pairing each Next.js rendering mode with a matching CDN recipe so you keep hit ratios high and data fresh.
Rendering Mode | Typical TTL | Recommended Cache-Control header |
Rationale |
---|---|---|---|
SSG (Static Site Generation) | Months / until next deploy | public, max-age=31536000, immutable |
Fingerprinted assets & prebuilt HTML never change; browsers and CDNs can keep them forever. |
ISR (Incremental Static Regeneration) | Seconds → minutes | public, s-maxage=60, stale-while-revalidate=300 |
First hit after TTL sees cached page; edge quietly re-renders in the background. |
SSR (Server-Side Rendering) | Seconds | public, s-maxage=30, must-revalidate |
Good for anonymous traffic where data changes often but not that often. |
Edge/Streaming | Per request | Usually uncached or keyed on cookie/header | Personalization or low-latency computations—better to skip CDN storage unless variant keys are tight. |
Tip: CDNs ignore max-age in the presence of s-maxage, so always set the latter for edge-side decisions and let browsers fall back to the former.
How a Request Flows Through the Layers
graph LR
A[Browser] -->|GET /product/slug| B[CDN POP]
B -->|HIT| C[Serve Cached HTML]
B -->|MISS| D[Next.js Origin]
D --> E[Generate HTML]
E -->|Store & Stream| B
- A HIT terminates at B—the edge node responds in ±20 ms.
- A MISS walks down to your origin, but the freshly rendered HTML is pushed right back to the edge so the next visitor enjoys the shortcut.
Matching Strategy to Business Requirements
Scenario | Best Fit | Why |
---|---|---|
Product catalog that updates hourly | ISR | Fast reads, predictable refresh window. |
Flash-sale countdown page | SSR / Edge | Needs real-time stock & pricing, can’t risk staleness. |
Docs site or blog | SSG | Content seldom changes; max cache benefit. |
Sample Header Snippets
// SSG API Route (headers set at build time)
export const revalidate = false; // Static forever
// ISR Page component
export const revalidate = 60; // in seconds
export async function generateStaticParams() {/* ... */}
// SSR Route Handler
export async function GET() {
const res = await fetchProducts()
return NextResponse.json(res, {
headers: {
'Cache-Control': 'public, s-maxage=30, must-revalidate',
},
})
}
Avoiding Variant Explosion
-
Cookies: Strip or prefix them (
Cache-Control: public
) if the data is the same for guests. - Query Strings: In CloudFront, disable “Forward query strings” unless absolutely required.
- Accept-Language / Geo: Use Edge Middleware to funnel just the needed header values into the cache key instead of the entire header.
Dial these levers correctly and you’ll watch your edge hit ratio climb while your origin CPU naps. In the next section we’ll roll up our sleeves and write the exact Cache-Control
spells each CDN understands—plus the edge-specific quirks that can make or break those dreams of 95 % HITs worldwide.
Crafting Cache-Control Headers That CDNs Actually Obey
Setting a header is easy; choosing the right header that browsers and every POP between São Paulo and Seoul respect is the art form. Think of Cache-Control
as the recipe card you hand to the CDN: how long to keep the dish on the counter, when to toss it, and whether guests (browsers) may take leftovers home.
Decode the Cache-Control Lexicon
Directive | Who Reads It | What It Means in Practice |
---|---|---|
public |
Browsers, CDNs | Response is safe to store even if it contains cookies. |
max-age= |
Browsers | How long (in seconds) the browser can reuse the asset without re-checking the origin. |
s-maxage= |
CDNs only | Overrides max-age for shared caches. Your edge nodes live by this value. |
stale-while-revalidate= |
Browsers, CDNs* | Serve the stale copy immediately, but fire a background re-fetch to refresh the cache. |
immutable |
Browsers | “Don’t bother re-validating—this file is fingerprinted.” Perfect for /static/chunk-abc123.js . |
must-revalidate |
Browsers, CDNs | Once the TTL hits zero, fetch a fresh copy before replying. Good for sensitive price data. |
Not every CDN respects stale-while-revalidate yet. Vercel and Cloudflare do, CloudFront does not unless you run a Lambda@Edge shim.
Header Recipes for Common Next.js Pages
Static asset (JS, CSS, fonts)
(You get this free from next build
, but it’s handy to know what it means.)
Cache-Control: public, max-age=31536000, immutable
ISR page that rebuilds every minute, but should stay lightning-fast for users
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Edge rule of thumb: stale-while-revalidate
≥ 5 × s-maxage
keeps hit rates high while giving you a generous freshness window.
Anonymous SSR page that must never be older than 30 seconds
Cache-Control: public, s-maxage=30, must-revalidate
Personalized SSR page (skip caching entirely)
Cache-Control: private, no-store
By marking it private
, you instruct the CDN to bypass storage while still letting the browser cache it if you wish (rare for auth pages).
Implementing Headers in Next.js 15
App Router makes header setting almost trivial:
// app/(public)/blog/[slug]/page.tsx
import { headers } from 'next/headers';
export const revalidate = 60; // ISR, but we'll tune the edge TTL
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
// Edge TTL: 60 s, but allow 5 min stale serving
headers().set(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=300'
);
return <Article data={post} />;
}
For Route Handlers or traditional API routes:
export async function GET() {
const data = await expensiveQuery();
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=10, stale-while-revalidate=30',
},
});
}
Provider-Specific Gotchas
-
Vercel CDN
- Reads
s-maxage
religiously. - Exposes the verdict via
x-vercel-cache: HIT | MISS | STALE
. - Omits
stale-while-revalidate
if you accidentally appendprivate
.
- Reads
-
Cloudflare
- Treats
max-age
ands-maxage
the same unless you enable Cache Everything or write a Worker. - Cookies bust the cache unless you declare
cacheEverything()
in Workers or strip cookies upstream.
- Treats
-
AWS CloudFront
- Ignores
stale-while-revalidate
. - Caches by default on origin headers only; if you vary on
Accept-Language
, enable Header Whitelisting or Lambda@Edge.
- Ignores
Quick Sanity Checklist
-
Start with
public
unless the page is truly user-specific. -
Always provide
s-maxage
—otherwise the CDN falls back to the more conservativemax-age
. -
Keep
stale-while-revalidate
≥ 5×s-maxage
for high-traffic ISR routes. - Strip or hash volatile cookies in Edge Middleware so they don’t kill your cache.
- Watch your headers in DevTools → Network → Response; verify Age ticks up on refresh. No increase? You’re missing the cache.
Dial these headers in, and global POPs turn into mini-origins that serve your pages with coffee-shop proximity. Next we’ll leave theory behind and jump into the provider consoles—Vercel, Cloudflare, and CloudFront—to wire up the exact rules that make those headers come alive.
Configuring CDN Rules on Vercel, Cloudflare and AWS CloudFront
You’ve written the perfect Cache-Control
spells—now the edge needs to obey them unfailingly. Each CDN has its own levers, defaults, and gotchas. Get familiar with the dashboard quirks once and your hit-ratio graphs will stay green for life.
Vercel CDN – Native Integration Done Right
Vercel’s edge network is purpose-built for Next.js, so a lot “just works,” but there’s still room for fine-tuning.
What You Want | Where to Do It | Pro Tip |
---|---|---|
Override caching per route | Add export const revalidate = … or headers().set() in the App Router. |
Keep the project-level Performance → Caching setting on Automatic unless you’re debugging. |
Cache variants by cookie or header |
middleware.ts → const res = NextResponse.next({ request: { headers: { 'x-geo': geo } } })
|
Any header you append in Middleware becomes part of the cache key—cheap way to localize pages. |
Debug live traffic |
x-vercel-cache header |
HIT (served from edge), STALE (edge used SWR), MISS (origin). Aim for ≥ 90 % HIT/STALE . |
Purge instantly | Vercel Dashboard → Deployments → Invalidate Cache | Works per deployment; no wildcard path purges needed. |
// Example: Add locale to cache key without bloating cookies
export function middleware(req: NextRequest) {
const country = req.geo?.country || 'default';
const res = NextResponse.next();
res.headers.set('x-country', country);
return res;
}
Cloudflare CDN – Power Tools for the Tinkerer
Cloudflare won’t cache HTML unless you ask politely. Happily, you have three ways to ask.
1 Page Rules (the fastest on-ramp)
- Navigate to Rules → Page Rules → Create Rule.
- Pattern:
example.com/blog/*
- Set Cache Level → Cache Everything and Edge Cache TTL → 1 hour.
Great for blogs or marketing paths that never set auth cookies.
2 Transform Rules or Workers (full control)
export default {
async fetch(req, env, ctx) {
// Strip cookies to keep variant count down
const url = new URL(req.url);
if (url.pathname.startsWith('/api')) return fetch(req); // bypass
const newReq = new Request(req, { headers: new Headers(req.headers) });
newReq.headers.delete('cookie');
const res = await fetch(newReq);
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
return res;
},
};
Deploy as a Cloudflare Worker → “Routes” → match `*example.com/`.*
Workers respect stale-while-revalidate
, giving you near-instant refresh without cold misses.
3 R2 + KV for Dynamic HTML
For heavy traffic landing pages, render once in Next.js, store in KV, and serve directly from Workers—bypassing your origin entirely after the first request.
AWS CloudFront – Enterprise-Grade, More Knobs
CloudFront is rock-solid, but it expects explicit instructions.
Essential Behavior Settings
Setting | Recommended Value |
---|---|
Origin Cache Policy | CachingOptimized (static) or custom policy for HTML |
Header Forwarding | Whitelist only those you vary on (Accept-Language , custom AB header). |
Query Strings | None unless product filters live in the query. |
Cookies | Forward = None for public pages; Whitelist when needed. |
Minimum TTL |
0 (sec) so s-maxage always wins. |
Lambda@Edge for SWR-Like Behavior
CloudFront ignores stale-while-revalidate
, but you can emulate it:
// viewer-response.js
'use strict';
exports.handler = async (event) => {
const res = event.Records[0].cf.response;
const age = parseInt(res.headers.age?.[0]?.value || '0', 10);
// If object is older than 60 s, trigger async refresh
if (age > 60) {
const url = 'https://' + res.headers['x-amz-meta-refresh-origin'][0].value;
fetch(url, { method: 'PURGE' }).catch(() => {});
}
return res;
};
Attach this at the Viewer-Response stage and keep your TTL short (e.g., 60 s). The first viewer after expiry still gets the stale copy, but Lambda fires off the background refresh.
Cache Invalidation
aws cloudfront create-invalidation \
--distribution-id E123ABC456 \
--paths "/blog/*" "/products/*"
Trigger this script from your CMS webhook so editors see updates in seconds.
Universal Debugging Tricks
-
curl -I https://site.com
— CheckCache-Control
,age
,x-cache
orx-vercel-cache
. - Chrome DevTools → Network → Timing — Look for a sub-100 ms Waiting (TTFB) on a warm cache.
- Provider Analytics — Vercel Edge Network, Cloudflare Cache Analytics, CloudFront CloudWatch hit/miss graphs.
If numbers sag, inspect which header, cookie, or query param is secretly exploding your cache key space.
Your CDN is now primed, tuned, and ready to throw pages around the planet at the speed of light—well, as close as the laws of physics allow. Next up we’ll explore stale-while-revalidate
in depth and show how to deliver instant responses and background freshness without lifting a finger.
Harnessing stale-while-revalidate
for Instant Speed & Silent Freshness
Imagine serving every page in ±30 ms and letting the CDN refresh content while your visitor is already scrolling. That’s the promise of stale-while-revalidate (SWR) at the HTTP‐header level—not to be confused with the React data-fetching hook of the same name.
What SWR Really Does
-
During the TTL (
s-maxage
): the edge returns a pure HIT. -
After TTL but within SWR window:
- The CDN serves the stale copy immediately—still a sub-50 ms response.
- In the background it fetches a fresh version from your origin and replaces the cached object.
- After both TTL & SWR expire: the next request blocks until the origin responds (behaves like a traditional MISS).
Mental model: You trade eventual freshness for immediate speed, but only for a tightly bounded time window you control.
Choosing the Right Durations
Page Type | s-maxage |
stale-while-revalidate |
Why |
---|---|---|---|
News home page |
60 s |
300 s |
Visitors get near-real-time headlines; editors see updates within 5 min. |
Product catalog |
120 s |
600 s |
Prices rarely change more often than every 10 min; cache hit rate stays high. |
Live sports scores |
5 s |
20 s |
Ultra-tight TTL but still cushions the origin during viral spikes. |
Rule of thumb: make the SWR window 4–10 × the s-maxage value so most requests remain full-speed hits while the edge quietly refreshes.
End-to-End Example in Next.js
// app/products/page.tsx
import { headers } from 'next/headers';
export const revalidate = 120; // ISR fallback for safety
export default async function Products() {
const products = await getVisibleProducts();
// Tell the CDN: use for 2 min, serve stale for up to 10 min
headers().set(
'Cache-Control',
'public, s-maxage=120, stale-while-revalidate=600'
);
return <Catalog items={products} />;
}
Flow in real life
sequenceDiagram
participant User
participant Edge
participant Origin
User->>Edge: Request /products
Edge-->>User: Cached HTML (age 0-120s)
Note over Edge: 0-120 s: Perfect HIT
User2->>Edge: Request (age 180 s)
Edge-->>User2: Stale HTML (instant)
Edge->>Origin: Async re-fetch
Origin-->>Edge: Fresh HTML
Note over Edge: Cache updated, no user waits
User3->>Edge: Request (age 601 s)
Edge->>Origin: Wait for HTML
Origin-->>Edge: New HTML
Edge-->>User3: Fresh HTML