Type something to search...
Next.js Using a CDN with Next.js

Next.js Using a CDN with Next.js

Putting a CDN in front of a Next.js app sounds like a five-minute job. Point your DNS at Cloudflare or Fastly, flip on caching, and enjoy sub-50ms responses from the edge. For a plain static site, that is roughly true. For a Next.js App Router site, it is not, and the gap between "it looks like it's caching" and "it's actually caching correctly" is where most production incidents in this area come from.

The reason is that a Next.js route is not one response. The exact same URL can return full HTML for a hard browser refresh, a React Server Components (RSC) payload for a client-side navigation, or a smaller RSC fragment for a single segment update, and which one you get depends on a handful of request headers your CDN has probably never heard of. Cache the wrong variant under the wrong key and you either serve HTML to a client that expected RSC data (breaking client-side navigation) or you serve a stale RSC fragment as if it were a full page. Neither failure mode throws an error in your CDN dashboard. Both just quietly break navigation for a subset of users, which is exactly the kind of bug that's miserable to track down after the fact.

This article walks through what Next.js actually sends to a CDN today, why the App Router's response variability makes CDN caching harder than a typical static site, what you can safely ignore versus what will break navigation if you drop it, and where the framework is headed to make this whole problem go away.

Why This Isn't Just "Add a CDN and Done"

A traditional server-rendered app has one canonical response per URL. A CDN's job is simple: cache by pathname (plus maybe a cookie or two), respect the TTL, done.

The App Router breaks that assumption on purpose, because it needs to support three different client behaviors from the same route:

  • A full HTML document, for the first load or a hard navigation.
  • An RSC payload, for a client-side <Link> navigation that needs new component tree data without a full page reload.
  • A targeted segment update, for a navigation that only changes part of the tree (a nested layout stays mounted while a leaf page swaps out).

All three can live at the same pathname. Next.js tells them apart using request headers like rsc, next-router-state-tree, and next-router-prefetch. Browsers navigating with the built-in router send these automatically; a CDN that doesn't know to key on them will happily serve whichever variant it cached first to every subsequent request, headers be damned.

This is the crux of the whole article: Next.js's caching model is header-aware, and most CDN caching models are pathname-aware. Everything below is about bridging that gap.

What Cache-Control Headers Next.js Actually Sends

Before touching CDN configuration, it helps to know exactly what Next.js puts on the wire, because this part genuinely does work out of the box with any CDN that respects standard HTTP caching semantics.

Next.js sets Cache-Control based on how a given route renders:

Static pages (no revalidation configured) get a full year of shared caching:

Cache-Control: s-maxage=31536000

ISR pages (time-based revalidation) get a shorter s-maxage matched to your revalidation window, plus a stale-while-revalidate window on top of it:

Cache-Control: s-maxage=60, stale-while-revalidate=31536000

That default stale-while-revalidate value is one year unless you configure otherwise, which is worth knowing because it means a CDN (or browser) is allowed to keep serving a stale copy for a very long time while Next.js regenerates it in the background. If you want tighter control over both numbers, cacheLife lets you define named profiles:

// app/lib/cache-life.ts
import { unstable_cacheLife as cacheLife } from "next/cache";

export function productPageProfile() {
  cacheLife({
    stale: 60, // client can use a stale response for 60s
    revalidate: 300, // background revalidate every 5 minutes
    expire: 3600, // hard expiry after 1 hour
  });
}

Dynamic pages (no caching at all) get the header combination you'd expect from any framework that wants to stop shared caches from touching a response:

Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

If your CDN respects s-maxage and stale-while-revalidate — and essentially all of them do, since these are standard HTTP cache directives, not Next.js inventions — static and ISR pages will cache at the edge with zero extra configuration. This is the good news. The bad news is a layer underneath it: CDN-level caching doesn't know about on-demand revalidation.

The Gap: revalidateTag() Doesn't Reach Your CDN

When you call revalidateTag() or revalidatePath() inside a Server Action or Route Handler, you're invalidating the Next.js server's own cache. Your origin server will regenerate fresh content on the next request. Your CDN, sitting in front of that origin, has no idea any of this happened. It's still serving the copy it cached ten minutes ago, and it will keep doing so until s-maxage expires naturally.

This surprises people because on-demand revalidation feels instantaneous when you test it against localhost or a single-origin deployment without a CDN in the path. Add a CDN and that same code path becomes "instant at the origin, up to s-maxage seconds later at the edge," which is a meaningfully different guarantee if your content is time-sensitive (pricing, inventory, breaking news).

The fix is to treat revalidation as a two-step operation: invalidate the Next.js cache, then invalidate the CDN cache for the same keys.

// app/api/revalidate-product/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const { tag, paths } = await request.json();

  // Step 1: invalidate the Next.js server cache
  revalidateTag(tag);

  // Step 2: purge the same paths at the CDN
  await Promise.all(
    paths.map((path: string) =>
      fetch(`https://api.cdn-provider.com/v1/purge`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.CDN_API_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ path }),
      }),
    ),
  );

  return NextResponse.json({ revalidated: true });
}

A detail that's easy to miss here: purge both the HTML and RSC variants of a path. If your CDN keys on the _rsc search parameter (more on that shortly), purging /products/123 alone won't necessarily clear the RSC-flavored cache entry sitting under /products/123?_rsc=abc123. Check whether your CDN's purge API supports wildcard or prefix purging, and use that instead of trying to enumerate every variant key by hand.

Static Assets: The Part That's Actually Simple

Unlike pages, static assets under /_next/static/ are refreshingly boring to cache. Every file in that directory has a content hash baked into its filename, so the file at a given URL never changes — a new build produces new filenames instead of overwriting old ones. That means Next.js can tell CDNs to cache these forever:

Cache-Control: public, max-age=31536000, immutable

There's no invalidation problem here because there's nothing to invalidate. If you want these assets served from a dedicated CDN origin or subdomain (common if you're fronting your app with a different CDN than your static asset host, or sharding asset traffic away from your main domain), assetPrefix handles that:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  assetPrefix:
    process.env.NODE_ENV === "production"
      ? "https://static.example.com"
      : undefined,
};

module.exports = nextConfig;

Treat any CDN caching problems you hit as a pages problem, not an assets problem. If your static JS/CSS isn't caching, that's a CDN configuration issue unrelated to anything Next.js-specific.

Static Prefetches: Where Partial Prerendering Helps

If you have Partial Prerendering (PPR) enabled on a route, there's a specific case where CDN caching gets easier rather than harder: static prefetch requests.

When the client sends a prefetch request (identified by the next-router-prefetch header) for a PPR-enabled route, the response is deterministic — Next.js returns the same prerendered static shell regardless of what the client's router state currently looks like. The next-router-state-tree header, which normally affects the response for non-PPR routes, is ignored entirely for these prefetches.

That determinism means a CDN can safely cache static prefetch responses, as long as it does two things:

  1. Includes the _rsc search parameter in its cache key, so prefetch responses don't collide with HTML responses for the same pathname.
  2. Respects the Cache-Control header Next.js sends on the response, same as any other cacheable route.

If you're running Cache Components (the newer caching model), segment-level prefetches go a step further and use pathname-based routes directly — something like /page.segments/_tree.segment.rsc — which any CDN can cache with plain pathname-based keys, no header awareness required. This is a preview of the direction the whole framework is heading, which I'll get to below.

The Actual Hard Part: Headers CDNs Don't Understand

Here's the list of headers that make App Router responses vary, and that Next.js signals via a Vary response header:

  • rsc — is this request expecting an RSC payload instead of HTML?
  • next-router-state-tree — what does the client's router currently look like, so the server can return a targeted update instead of a full payload?
  • next-router-prefetch — is this a prefetch, not a real navigation?
  • next-router-segment-prefetch — which specific segment is being prefetched?
  • next-url — for intercepting routes, what URL is being intercepted?

Most CDNs, out of the box, do not vary their cache by arbitrary custom request headers. Some support it with extra configuration (Cloudflare's Cache Rules, Fastly's VCL); many don't support it at all in their default caching tier. If your CDN falls into the second bucket and you don't do anything about it, the first client to hit a URL determines what every subsequent client gets, regardless of whether they wanted HTML or RSC data.

Next.js's workaround is the _rsc search parameter: a hash of the relevant header values, appended to the request URL. Since it's part of the URL rather than a header, any CDN that caches by full URL (including query string) will naturally get correct cache-key separation without needing to understand Vary at all. This is a clever trick, but it depends entirely on your CDN not stripping query parameters before checking its cache — and a fair number of CDN default configurations do exactly that, because query strings are often treated as "cache-busting noise" to be normalized away. If you inherited a CDN config that strips query params for "better cache hit rates," you've silently broken RSC/HTML separation for every App Router site behind it.

One more good-to-know here: proxy.js (the file that replaced Middleware in this version of Next.js) should sit in front of your CDN cache, not behind it, because it's the source of truth for auth checks, redirects, and rewrites. If your CDN is positioned in a way that serves a cached response before proxy.js gets a chance to run, you can end up serving cached content to a user who should have been redirected to a login page. If your deployment architecture makes it hard to put proxy.js first, at minimum configure the CDN to bypass its cache for any route where proxy.js makes access-control decisions.

What You Can Safely Drop vs. What You Must Preserve

Not every header on this list is equally load-bearing. Some degrade gracefully if a CDN strips them; others break navigation outright.

Safe to omit, with a graceful fallback:

  • next-router-state-tree — if missing on a non-prefetch RSC request, the server just returns the full payload instead of a smaller targeted update. Slower, not broken.
  • next-router-segment-prefetch — if missing on a prefetch request, the server falls back to a broader prefetch payload instead of the specific segment. More bytes over the wire, not a functional break.
  • next-url — if missing, intercepting routes stop being intercepted; the user just sees the regular target page instead of the modal/overlay version. Worth knowing if your app relies on intercepting routes for things like photo modals or quick-view panels — it degrades to "normal navigation," which is usually acceptable but is a UX regression worth testing for deliberately.

Must be preserved, or navigation breaks:

  • rsc — if a CDN strips this and serves HTML to a client-side router expecting an RSC payload, the client-side router falls back to a full browser navigation. Every internal link effectively turns into a full page reload. This is the single most damaging header to lose, and it's also the easiest to lose silently, because the page still "works" — it's just slower and jankier in a way that's easy to miss in a quick smoke test.
  • next-router-prefetch together with the _rsc parameter — for prefetch requests specifically, _rsc is not optional cache-key hygiene, it's a required discriminator. Treat it as mandatory whenever this header is present.
  • The _rsc search parameter itself — must survive into your cache key. If your CDN strips query parameters (a common default "optimization"), disable that behavior for this app, or scope it to exclude _rsc specifically.

By default, if an RSC request arrives with a missing or incorrect _rsc value, Next.js responds with a 307 redirect to the URL carrying the correct hash — so even a misconfigured CDN that drops the parameter on the way in will get corrected, as long as it follows redirects. You can turn this safety net off via experimental.validateRSCRequestHeaders: false in next.config.js, but I'd only do that if you've verified your CDN and edge setup preserve _rsc reliably; the redirect is cheap insurance against a subtle misconfiguration you haven't caught yet.

Testing This Properly

Because none of these failure modes throw a visible error, the only reliable way to catch them is to check headers directly, on a staging environment that mirrors your production CDN configuration:

# Simulate a client-side RSC navigation
curl -sD - -o /dev/null \
  -H "RSC: 1" \
  -H "Next-Router-Prefetch: 1" \
  "https://staging.example.com/products/123?_rsc=abc123"

Look for two things in the response: does the Cache-Control header match what you'd expect for that route's rendering strategy, and does the _rsc value in the request URL actually change the response you get back compared to a plain request without it? If a second request without _rsc returns byte-identical content to the RSC-flavored one, your CDN isn't separating cache keys correctly, and you'll want to fix that before it ships.

It's also worth deliberately testing the "stale after purge" scenario: trigger a revalidateTag() call, then immediately hit the page through the CDN (not the origin) and confirm you get fresh content, not the pre-purge cached copy. This is the scenario that tends to get skipped in testing because it "worked in dev," where there's no CDN in the path to hide the problem.

Configuring Popular CDNs to Actually Respect This

The advice above is easy to state in the abstract and surprisingly fiddly to translate into a specific CDN's dashboard. A few concrete starting points, based on where teams most commonly get tripped up:

Cloudflare. The default cache configuration keys almost entirely on the URL path and ignores most headers and, depending on your zone settings, may also normalize away query strings. You want a Cache Rule (not a Page Rule — Cache Rules are the newer, more granular mechanism) that does two things: includes the full query string in the cache key (so _rsc survives), and adds a "Cache Key" custom field for the rsc header if your plan tier supports vary-by-header. If it doesn't, leaning entirely on the _rsc search parameter is your fallback, which is exactly why Next.js designed it as a URL parameter instead of purely a header — it works even on plans that can't vary by header.

Fastly. Because Fastly is configured with VCL, you have direct control over the cache key. A minimal adjustment to fold the rsc header into the hash looks like this:

sub vcl_hash {
  set req.hash += req.url;
  if (req.http.rsc) {
    set req.hash += req.http.rsc;
  }
  if (req.http.next-router-prefetch) {
    set req.hash += req.http.next-router-prefetch;
  }
  return (hash);
}

This is more explicit and more reliable than depending on query-string preservation alone, since it varies at the header level the way Next.js's own Vary response header intends. If you're already comfortable writing VCL, this is the more robust option of the two approaches described in this article.

A managed platform's built-in Next.js integration (Vercel being the most direct example, but several other platforms now ship purpose-built Next.js adapters) generally handles all of this for you automatically, because the CDN layer is built with specific knowledge of the framework's response variance rather than treating Next.js as a generic backend. If you're on one of these platforms, most of this article is background knowledge rather than a checklist — you can skip straight to verifying behavior rather than configuring it by hand. If you're fronting a self-hosted Next.js deployment with a general-purpose CDN, everything above is squarely your responsibility.

Where This Is Headed: Pathname-Based Cache Keys

Everything above is Next.js coping with the fact that response variance currently lives in headers, which most CDN infrastructure wasn't built to key on. The team's stated direction is to eliminate that mismatch entirely by moving all cache-affecting inputs into the pathname itself.

The mechanism already exists in a limited form — output: 'export' and segment prefetches already use file-extension-based pathnames — and the plan is to generalize it:

  • A full page's RSC payload would live at something like /products/123.rsc.
  • A specific segment's RSC payload would live at something like /products/123.segments/reviews.segment.rsc.

Under this model, the pathname alone determines which variant you get. Search parameters become safely droppable, Vary support becomes unnecessary, and a CDN that just does plain path-based caching — the simplest, most universally supported kind — works correctly by default. The one place this changes behavior is intercepting routes: instead of next-url implicitly affecting the response, that variability would live in a search parameter. A CDN that preserves query strings continues to support intercepting routes correctly; one that strips them gracefully degrades to the non-intercepted page instead of breaking navigation, making that support genuinely opt-in rather than a hard requirement.

This is still in active design as of this Next.js version, not something you can flip on today, but it's worth understanding where things are headed if you're making long-term CDN architecture decisions — the header-juggling described in this article is very likely a transitional state, not the permanent shape of the problem.

Mistakes I See Constantly

A few patterns show up often enough in real deployments that they're worth calling out by name, since each one is invisible until someone notices navigation feels "off":

Turning on "aggressive caching" or "cache everything" mode. Some CDNs offer a one-click setting that ignores origin cache headers and caches every response for a fixed TTL regardless of what it is. This is exactly wrong for a Next.js app: it will happily cache a private, no-store dynamic response as if it were static, serving one user's personalized or authenticated content to the next visitor. Always respect origin Cache-Control headers rather than overriding them wholesale.

Purging by exact path only. If your purge logic calls the CDN's purge API with the bare path /products/123 but the actual cached entries live under /products/123?_rsc=abc123 and /products/123?_rsc=def456 (different RSC hash values for different router states), an exact-match purge misses them entirely. Use prefix or wildcard purging wherever your CDN supports it, and confirm with a follow-up request that the purge actually took effect.

Assuming a CDN health check or synthetic monitor proves caching is correct. A monitoring bot typically requests plain HTML with no rsc header and no _rsc parameter, which is the one request variant most CDNs already handle correctly without any special configuration. It tells you nothing about whether client-side RSC navigation is being cached and served correctly. Test with the actual headers the router sends, not just a bare GET.

Forgetting that local development never exercises any of this. Every one of these failure modes requires an actual CDN sitting between the browser and Next.js. next dev, and even next start behind a plain reverse proxy with no caching layer, will never reproduce a stale-RSC or stripped-header bug. If your only pre-production environment lacks a CDN in the request path, you're shipping this entire category of bug untested every time.

Putting proxy.js-dependent routes behind a CDN cache without exclusions. If a route's access depends on proxy.js running a redirect or rewrite, and the CDN caches that route's response before proxy.js gets a chance to run on a given request, you can serve a cached "authenticated" page to a user who should have been bounced to a login screen. Explicitly exclude proxy-controlled routes from CDN caching, or make sure your architecture genuinely runs proxy.js upstream of the cache layer for every request, not just the first one.

Key Takeaways

ConcernWhat to do
Static & ISR pagesNothing extra needed — respect s-maxage / stale-while-revalidate, which any standards-compliant CDN already does
Static assets (/_next/static/)Already cached forever via content-hashed filenames; use assetPrefix to serve from a separate origin
On-demand revalidationPair every revalidateTag()/revalidatePath() call with an explicit CDN purge for the same paths (HTML and RSC variants)
rsc headerMust reach the origin unmodified — stripping it silently breaks client-side navigation into full page reloads
_rsc search parameterMust be part of your cache key — disable any CDN "strip query params" setting for this app
next-router-state-tree / next-router-segment-prefetch / next-urlSafe to drop — each degrades gracefully rather than breaking
proxy.jsMust run before the CDN cache, or configure the CDN to bypass caching on routes it controls

CDN caching a Next.js app isn't harder because Next.js does anything exotic with HTTP — it's harder because the App Router legitimately serves multiple response shapes from one URL, and most CDN products were designed around the assumption that one URL means one response. Once you internalize that single fact, every header on this page stops looking arbitrary and starts looking like exactly what it is: the framework's current best answer to a problem that pathname-based routing will eventually make obsolete.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper