Skip to content

Commit 83f8091

Browse files
pi0claude
andcommitted
docs: add ISR caching section to README
Document how to emulate Vercel-style Incremental Static Regeneration with defineCachedHandler (swr + omitted staleMaxAge), on-demand revalidation via .expire()/.invalidate(), per-route windows via getMaxAge, and the two divergences from managed ISR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3996d2e commit 83f8091

1 file changed

Lines changed: 55 additions & 0 deletions

File tree

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,61 @@ const handler = defineCachedHandler(myHandler, {
190190

191191
`shouldCache` receives the serialized response entry, may be async, and is **ANDed** with the built-in checks — it can only narrow what gets cached, never force-cache a response the built-ins reject. It gates both storing a fresh response and serving a stored one, and a throwing hook fails closed (treated as non-cacheable) and is reported via `onError`.
192192

193+
#### Incremental Static Regeneration (ISR)
194+
195+
you can reproduce a similar ISR behavior with `defineCachedHandler`: serve a cached page instantly, regenerate it in the background after it goes stale, and keep serving the last-good version until the refresh lands:
196+
197+
```ts
198+
const page = defineCachedHandler(
199+
async (event) => {
200+
const html = await renderPage(event.url ?? new URL(event.req.url));
201+
return new Response(html, { headers: { "content-type": "text/html" } });
202+
},
203+
{
204+
swr: true, // serve stale instantly, refresh in the background
205+
maxAge: 60, // "revalidate" window: fresh for 60s, then refresh on next request
206+
// no staleMaxAge → stale is served indefinitely until the refresh succeeds
207+
},
208+
);
209+
```
210+
211+
The two options that make it ISR-like:
212+
213+
- **`swr: true`** turns on stale-while-revalidate: once an entry is older than `maxAge`, the next request gets the stale page immediately while a fresh render runs in the background.
214+
- **Omit `staleMaxAge`.** This is the important part. Leaving it unset means there's no point at which the entry becomes "too old to serve" — the last successful render is served forever until a refresh replaces it, exactly like ISR. (If instead you _set_ `staleMaxAge`, you get a hard cutoff: after `maxAge + staleMaxAge` the entry is dropped and the next request blocks on a fresh render.)
215+
216+
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.
217+
218+
**On-demand revalidation** (the equivalent of `revalidatePath` / `revalidateTag`) uses the methods on the returned handler:
219+
220+
```ts
221+
await page.expire(event); // ISR-style: serve the stale page once more, refresh in the background
222+
await page.invalidate(event); // hard purge: next request blocks on a fresh render
223+
```
224+
225+
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.
226+
227+
**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. `entry.value` is the standard `Response`:
228+
229+
```ts
230+
const page = defineCachedHandler(
231+
async (event) => {
232+
const url = event.url ?? new URL(event.req.url);
233+
const { html, revalidate } = await renderPage(url);
234+
return new Response(html, {
235+
headers: { "content-type": "text/html", "x-revalidate": String(revalidate) },
236+
});
237+
},
238+
{
239+
swr: true,
240+
getMaxAge: (entry) => Number(entry.value.headers.get("x-revalidate")) || 60,
241+
},
242+
);
243+
```
244+
245+
> [!NOTE]
246+
> Two things differ from CDN managed ISR. **(1) 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](#custom-storage) if regeneration is expensive. **(2) Entries never auto-expire** with `staleMaxAge` omitted, so storage grows until you `.invalidate()` — or set a large `staleMaxAge` to trade exact ISR semantics for eventual cleanup.
247+
193248
### Cache Invalidation
194249

195250
Cached functions have an `.invalidate()` method that removes cached entries across all base prefixes:

0 commit comments

Comments
 (0)