Implementing Stale-While-Revalidate in Next.js
This guide is part of Stale-While-Revalidate at the Edge. It walks through a concrete task: serving a Next.js App Router Route Handler from the Vercel Edge cache with a stale-while-revalidate window, so the route returns instantly from cache and refreshes in the background.
The problem
You have a Route Handler that proxies a slow upstream API — say a product feed that takes 400 ms to fetch. Calling it on every request is too slow and hammers the upstream. A long cache TTL fixes the latency but ships stale prices. You want the route to serve a cached copy in single-digit milliseconds, treat the copy as fresh for a minute, then serve it stale for several minutes while Next.js refreshes it behind the scenes.
Root cause: which cache layer Next.js actually uses
The confusion here is that Next.js exposes several caching surfaces and they do not all behave the same on the Edge Runtime. The Data Cache (driven by fetch(..., { next: { revalidate } })) and the Route Handler response cache both ultimately emit CDN-Cache-Control / Vercel-CDN-Cache-Control directives that the Vercel global edge cache obeys. The stale-while-revalidate behavior is enforced by that CDN layer, not by your handler code. So the task reduces to: pin the handler to the Edge Runtime, set a revalidate window, and emit the right Cache-Control directives. Because the edge isolate is torn down after the response, you do not — and must not — try to run the background refresh yourself; the CDN does it.
Step-by-step
Step 1 — Pin the Route Handler to the Edge Runtime
Declare the runtime and a revalidation window at the top of the handler. revalidate is the freshness window in seconds.
// app/api/products/route.ts
export const runtime = "edge";
export const revalidate = 60; // fresh for 60s, then revalidated
Step 2 — Fetch the upstream with an explicit revalidate hint
Use the next.revalidate option on fetch so the Data Cache stores the upstream payload and reuses it across requests rather than re-fetching every time.
// app/api/products/route.ts
export async function GET(): Promise<Response> {
const upstream = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
const body = await upstream.text();
return new Response(body, {
status: upstream.status,
headers: { "Content-Type": "application/json" },
});
}
Step 3 — Emit explicit CDN-Cache-Control directives
To control the stale window precisely, set CDN-Cache-Control on the response. This targets the CDN only, leaving browser caching under your separate control.
// app/api/products/route.ts
return new Response(body, {
status: upstream.status,
headers: {
"Content-Type": "application/json",
// CDN: fresh 60s, serve stale up to 10min while revalidating, survive errors 1 day.
"CDN-Cache-Control":
"public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400",
// Browser: revalidate every request, but accept a brief stale window.
"Cache-Control": "public, max-age=0, must-revalidate",
},
});
Step 4 — Confirm cache status
Deploy and request the route twice. The first response carries x-vercel-cache: MISS; the second, within the stale window, carries HIT or STALE and returns in single-digit milliseconds.
curl -sI https://your-app.vercel.app/api/products | grep -i x-vercel-cache
Step 5 — Add on-demand invalidation for correctness
TTL handles routine freshness, but when a product genuinely changes you want it gone now. Tag the fetch and call revalidateTag from a mutation route. This ties into tag-based invalidation.
// in the GET handler:
await fetch("https://api.example.com/products", {
next: { revalidate: 60, tags: ["products"] },
});
// app/api/products/revalidate/route.ts
import { revalidateTag } from "next/cache";
export const runtime = "edge";
export async function POST(): Promise<Response> {
revalidateTag("products");
return new Response(null, { status: 204 });
}
Config snippet
If you prefer page-level caching over a Route Handler, the same directive applies via next.config.js headers:
// next.config.js
module.exports = {
async headers() {
return [
{
source: "/api/products",
headers: [
{
key: "CDN-Cache-Control",
value: "public, s-maxage=60, stale-while-revalidate=600",
},
],
},
];
},
};
Local vs production divergence
| Behavior | next dev (local) |
Vercel (production) |
|---|---|---|
| CDN cache | Not present; every request hits the handler | Global edge cache honors CDN-Cache-Control |
x-vercel-cache header |
Absent | MISS / HIT / STALE |
| Background revalidation | Synchronous on next request | CDN refreshes out of band |
revalidateTag effect |
In-process Data Cache only | Purges across the edge fleet |
stale-if-error |
Not exercised | Serves stale on upstream 5xx |
The key trap: SWR looks like it “does nothing” locally because there is no CDN. Always validate the stale behavior in a preview deployment, not next dev.
Vitest validation
Test the handler’s directive output in isolation — you are asserting the contract the CDN consumes, which is the part you control.
// app/api/products/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify([{ id: 1 }]), { status: 200 }),
),
);
});
describe("GET /api/products", () => {
it("emits a stale-while-revalidate CDN directive", async () => {
const res = await GET();
const cdn = res.headers.get("CDN-Cache-Control") ?? "";
expect(cdn).toContain("s-maxage=60");
expect(cdn).toContain("stale-while-revalidate=600");
});
it("returns the upstream payload", async () => {
const res = await GET();
expect(await res.json()).toEqual([{ id: 1 }]);
});
});
Pitfalls
- Testing SWR in
next dev. There is no CDN locally, so stale serving never happens. Fix: validate in a preview deployment and readx-vercel-cache. - Setting only
Cache-Control, notCDN-Cache-Control. Browsers and the CDN then share one directive and you lose independent control. Fix: setCDN-Cache-Controlfor the edge and a separateCache-Controlfor the browser. - Caching authenticated responses. A handler reading
cookies()will cache one user’s data for all. Fix: setexport const dynamic = "force-dynamic"or skip caching on authenticated routes. - Forgetting
runtime = "edge". The handler runs in the Node.js serverless runtime with different cache semantics and cold-start behavior. Fix: declare the runtime explicitly. revalidateands-maxagedisagree. Conflicting windows produce surprising freshness. Fix: keeprevalidate,s-maxage, and thenext.revalidatehint numerically aligned.
Production deployment checklist
-
export const runtime = "edge" -
CDN-Cache-Controlcarriess-maxage+stale-while-revalidate(+stale-if-error - Browser
Cache-Control - No
cookies()/headers() -
revalidateTag - Stale behavior verified in a preview deployment via
Frequently Asked Questions
Why does stale-while-revalidate not work in next dev?
next dev runs no CDN, so there is nothing to serve a stale copy or revalidate in the background — every request hits your handler directly. The stale behavior only appears once the Vercel global edge cache is in front of the route. Validate it in a preview deployment and inspect the x-vercel-cache response header.
What is the difference between Cache-Control and CDN-Cache-Control here?
Cache-Control is honored by browsers and shared caches alike, while CDN-Cache-Control (and Vercel-CDN-Cache-Control) targets only the CDN. Setting them separately lets you serve stale-while-revalidate from the edge while keeping the browser on a stricter policy, which is usually what you want for an API route.
Should I run the background refresh myself with waitUntil?
Not in this setup. When you delegate to CDN-Cache-Control/revalidate, the Vercel edge cache performs the background revalidation for you. Reach for waitUntil and the Cache API only when you need behavior the declarative layer cannot express, such as a fully custom cache key.
How do I invalidate immediately when a product changes?
Tag the upstream fetch with next.tags, then call revalidateTag("products") from a mutation route. This purges every cached entry carrying that tag across the edge fleet, so the next request fetches fresh data regardless of the remaining TTL.