Incremental Static Regeneration
You can reproduce ISR-style behavior with defineCachedHandler: serve a cached page instantly, regenerate it in the background once it goes stale, and keep serving the last good version until the refresh lands.
const page = defineCachedHandler(
async (event) => {
const url = event.url ?? new URL(event.req.url);
const html = await renderPage(url);
return new Response(html, { headers: { "content-type": "text/html" } });
},
{
swr: true, // serve stale instantly, refresh in the background
maxAge: 60, // "revalidate" window: fresh for 60s, then refresh on next request
// no staleMaxAge → the last good page is served until a refresh replaces it
},
);
Two options make it ISR-like:
swr: trueturns on stale-while-revalidate: once an entry is older thanmaxAge, the next request gets the stale page immediately while a fresh render runs in the background.- Omit
staleMaxAge. This is the important part. Leaving it unset means there's no point at which an entry becomes "too old to serve" — the last successful render is served indefinitely until a refresh replaces it, exactly like ISR. If you instead setstaleMaxAge, you get a hard cutoff: aftermaxAge + staleMaxAgethe entry is dropped and the next request blocks on a fresh render.
With this config the handler also emits Cache-Control: s-maxage=60, stale-while-revalidate, so any shared/CDN cache in front of it revalidates on the same schedule. (Set staleMaxAge and the directive becomes s-maxage=60, stale-while-revalidate=<staleMaxAge>.)
Cache-Control, ocache leaves it untouched and won't synthesize one. See Cache-Control.On-demand revalidation
The handler returned by defineCachedHandler exposes .expire(), .invalidate(), and .resolveKeys() — the equivalent of Next's revalidatePath / revalidateTag. Each takes the request event and derives the exact key the handler cached it under, so there's no key to reconstruct:
const page = defineCachedHandler(
async (event) => {
const url = event.url ?? new URL(event.req.url);
return new Response(await renderPage(url), {
headers: { "content-type": "text/html" },
});
},
{ swr: true, maxAge: 60 },
);
// ISR-style: serve the stale page once more, refresh in the background
await page.expire(event);
// Hard purge: next request blocks on a fresh render
await page.invalidate(event);
Prefer .expire() for the ISR feel — there's no blocking gap for visitors. Reach for .invalidate() only when the next reader must get a guaranteed-fresh render. See Invalidation & Expiration for the underlying semantics.
varies headers and allowQuery / allowCookies values on the request. Pass an event that matches the variant you want to revalidate. For revalidation from somewhere without an event (a webhook, a cron job), the standalone expireCache / invalidateCache helpers rebuild the key from options instead.Per-route revalidate windows
If different pages need different refresh intervals (like Next's per-fetch revalidate), use getMaxAge to derive the window from the response — for example an x-revalidate header your handler sets. Inside getMaxAge, entry.value is the standard Response:
const page = defineCachedHandler(
async (event) => {
const url = event.url ?? new URL(event.req.url);
const { html, revalidate } = await renderPage(url);
return new Response(html, {
headers: { "content-type": "text/html", "x-revalidate": String(revalidate) },
});
},
{
swr: true,
getMaxAge: (entry) => {
const revalidate = entry.value.headers.get("x-revalidate");
return revalidate ? Number(revalidate) : 60; // missing header → default window
},
},
);
Differences from CDN-managed ISR
- Background refresh is coalesced per instance, not globally — across multiple servers / serverless instances the origin can see one refresh per instance. Add a distributed lock in your custom storage if regeneration is expensive.
- Entries never auto-expire with
staleMaxAgeomitted, so storage grows until you purge withinvalidateCache— or set a largestaleMaxAgeto trade exact ISR semantics for eventual cleanup. - A browser/CDN can still serve stale after a purge —
invalidateCache/expireCacheonly clear server-side storage; a client or CDN that already cached the synthesizedCache-Controlresponse keeps serving it, unaware of the purge. SetsendCacheControl: falseto keep caching decisions entirely server-side.