
How to cache on-demand rendered pages in Astro?
To cache on-demand rendered pages in Astro, configure a cache provider with the top-level cache option in astro.config.mjs, then tell Astro what to cache either declaratively with routeRules or per request with Astro.cache.set(). Both accept a maxAge in seconds, an optional swr (stale-while-revalidate) window and tags for targeted invalidation. Astro's built-in memoryCache() provider stores rendered responses in memory on a single server, while the Netlify, Vercel and Cloudflare adapters offer providers that push the same rules to their CDNs. When content changes, call cache.invalidate() with a tag or path to purge it.
Route caching arrived as an experimental feature and became stable in Astro 7, so cache and routeRules now live at the top level of your config rather than under experimental. It gives on-demand pages much of the speed of static ones without giving up fresh data. In this article you'll learn how the caching model works, how to set up the memory provider, how to write route rules and per-page directives, how to invalidate entries, how to use CDN providers, and the pitfalls with personalised pages that I ran into while testing.
Why Cache On-Demand Pages?
A prerendered page is generated once at build time and served as a file, usually from a CDN. An on-demand rendered page runs your component code on every request: it queries databases, calls APIs and renders HTML before sending a single byte. That's what you want for fresh or request-specific content, but it has costs:
- Slower responses: the time spent rendering adds directly to Time to First Byte and Largest Contentful Paint.
- More load: every visitor hits your server and your data sources.
- Higher bills: on serverless platforms, every request is a function invocation.
Caching lets you render once and reuse the result for a period you choose, while keeping the option to render on demand when something changes.
How Astro Route Caching Works
The feature has three parts:
- A cache provider: decides how caching actually happens. A runtime provider stores responses in your server process; a CDN provider translates your rules into headers that the hosting platform's edge cache understands.
- Cache directives: the rules for a response, made up of
maxAge,swr,tags,lastModifiedandetag. - Where you set directives: in config with
routeRules, in pages withAstro.cache, and in endpoints and middleware withcontext.cache.
Route caching only applies to on-demand rendered routes, so you'll need an adapter and either output: "server" or export const prerender = false on the routes involved.
Setting Up the Memory Cache Provider
The simplest provider is memoryCache(), an in-memory LRU cache imported from astro/config. It's suitable for a single long-running server, such as the Node adapter in standalone mode:
npx astro add node
// astro.config.mjs
import { defineConfig, memoryCache } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
cache: {
provider: memoryCache({ max: 500 }),
},
});
max is the maximum number of entries kept before the least recently used one is evicted. It defaults to 1000.
The memory provider also normalises cache keys to improve hit rates:
- Query parameters are sorted, so
?b=2&a=1and?a=1&b=2share an entry. - Tracking parameters are ignored by default, including
utm_*,fbclidandgclid, so marketing links don't fragment the cache. - Vary is respected: a response with
Vary: Accept-Languageis cached separately per language. TheCookieheader is always ignored for this purpose.
You can control which query parameters count with the query option, for example memoryCache({ query: { include: ["page", "sort"] } }) to key only on those two.
Caching Groups of Routes with routeRules
routeRules sets cache directives for route patterns in your config. It's the quickest way to cache a whole section of a site:
// astro.config.mjs
import { defineConfig, memoryCache } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
cache: {
provider: memoryCache({ max: 500 }),
},
routeRules: {
"/blog/[...slug]": { maxAge: 300, swr: 60 },
"/api/[...path]": { swr: 600 },
},
});
Patterns use the same syntax and priority rules as file-based routing:
- Static paths:
/about,/api/health - Dynamic parameters:
/products/[id] - Rest parameters:
/docs/[...path]to match everything under/docs
Glob wildcards such as * aren't supported. Use a rest parameter instead.
Here's what the blog rule did with a simple page that prints the time it was rendered. The first request rendered the page; the second was served from the cache with the same timestamp:
curl -si localhost:4329/blog/hello | grep -iE "x-astro|Rendered"
curl -si localhost:4329/blog/hello | grep -iE "x-astro|Rendered"
x-astro-cache: MISS
<!DOCTYPE html><html lang="en"><head><title>hello</title></head><body><h1>hello</h1><p>Rendered at 2026-10-24T05:08:31.656Z</p></body></html>
x-astro-cache: HIT
<!DOCTYPE html><html lang="en"><head><title>hello</title></head><body><h1>hello</h1><p>Rendered at 2026-10-24T05:08:31.656Z</p></body></html>
Runtime providers add the X-Astro-Cache header for observability. Its value is MISS for a fresh render, HIT for a cached response and STALE when a stale response is served while a new one is rendered in the background.
Setting Cache Options per Page
For finer control, call Astro.cache.set() in a page's frontmatter. This is useful when the cache duration or tags depend on the data:
---
// src/pages/products/[id].astro
const { id } = Astro.params;
if (Astro.cache.enabled) {
Astro.cache.set({
maxAge: 3600,
swr: 300,
tags: ["products", `product:${id}`],
});
}
const renderedAt = new Date().toISOString();
---
<html lang="en">
<head><title>Product {id}</title></head>
<body>
<h1>Product {id}</h1>
<p>Rendered at {renderedAt}</p>
</body>
</html>
The options are:
| Option | Type | Meaning |
|---|---|---|
maxAge | number | Seconds the response is considered fresh |
swr | number | Seconds a stale response may be served while a new one renders in the background |
tags | string array | Labels used to invalidate groups of entries |
lastModified | Date | The content's last modification date |
etag | string | An entity tag for conditional requests |
Astro.cache.enabled is false when no provider is configured and always false in development, where caching is a no-op. Without a provider, cache.set() logs a warning, so the check keeps logs clean in projects where caching is optional. To see caching work locally, run astro build and start the built server.
When I requested this page, a URL with a tracking parameter reused the cached entry, while a different meaningful parameter created a new one:
/products/42 x-astro-cache: MISS
/products/42 x-astro-cache: HIT
/products/42?utm_source=news x-astro-cache: HIT
/products/42?page=2 x-astro-cache: MISS
Caching Endpoints
API routes and middleware use context.cache, which has the same methods:
// src/pages/api/time.ts
import type { APIRoute } from "astro";
export const GET: APIRoute = ({ cache }) => {
cache.set({ maxAge: 30, tags: ["time"] });
return Response.json({ now: new Date().toISOString() });
};
The API endpoints guide covers endpoints in general.
How Directives Merge
Route rules and cache.set() calls are combined rather than replacing each other. Within one request:
- maxAge, swr and etag: the last value set wins.
- lastModified: the most recent date wins.
- tags: accumulate across every call.
So middleware can add a site-wide tag, a route rule can set a default maxAge, and a page can extend or shorten it.
Opting Out of Caching
Pass false to cache.set() to skip caching for a request, even when a route rule matches. For example, to bypass the cache for editors previewing a post:
---
// src/pages/blog/[...slug].astro
const { slug } = Astro.params;
if (Astro.url.searchParams.has("preview")) {
Astro.cache.set(false);
}
---
In my test, two requests to /blog/hello?preview each rendered fresh with different timestamps, while /blog/hello without the parameter was still cached.
Personalised Pages: a Pitfall to Avoid
Caching is keyed by URL, plus any request headers named in Vary, and cookies are never part of the key. That has an important consequence I saw while testing. I wrote an account page that opted out of caching for signed-in visitors and cached the signed-out version for a minute:
---
// src/pages/account.astro
const session = Astro.cookies.get("session")?.value;
if (session) {
Astro.cache.set(false);
} else {
Astro.cache.set({ maxAge: 60 });
}
---
<p>{session ? "Welcome back" : "Please sign in"}</p>
After one anonymous visit, a request with a session cookie received the cached anonymous page:
x-astro-cache: HIT
<p>Please sign in</p>
The runtime cache answers before your page code runs, so the cache.set(false) branch never gets a chance. The same applies with a route rule. The safe rules are:
- Never cache a URL whose HTML depends on cookies or authentication. Leave those routes out of
routeRulesand don't callcache.set()on them. - Split public and personal content: cache the public page and load the personal parts separately, for example with server islands or a client-side request to an uncached endpoint.
- Use separate URLs for genuinely different variants, such as
/en/and/fr/, orVaryon a low-cardinality header likeAccept-Language.
Invalidating Cached Entries
When content changes, you don't have to wait for maxAge to expire. cache.invalidate() purges entries by tag or by exact path. A common pattern is a protected endpoint that your CMS calls from a webhook:
// astro.config.mjs (excerpt)
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
REVALIDATE_SECRET: envField.string({ context: "server", access: "secret" }),
},
},
});
// src/pages/api/revalidate.ts
import type { APIRoute } from "astro";
import { REVALIDATE_SECRET } from "astro:env/server";
export const POST: APIRoute = async ({ request, cache }) => {
if (request.headers.get("x-revalidate-secret") !== REVALIDATE_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const { tag, path } = await request.json();
if (tag) await cache.invalidate({ tags: [tag] });
if (path) await cache.invalidate({ path });
return Response.json({ purged: { tag, path } });
};
The secret is read through astro:env at runtime. In Astro 6 and later, import.meta.env values are inlined at build time, so a secret that only exists on the server at runtime would be missing; the type-safe environment variables guide explains the difference.
Testing it against the product page:
curl -s -X POST localhost:4329/api/revalidate \
-H "x-revalidate-secret: s3cret" \
-H "content-type: application/json" \
-d '{"tag":"product:42"}'
{"purged":{"tag":"product:42"}}
The next request to /products/42 was a MISS with a new timestamp. Purging { "path": "/blog/hello" } worked the same way. Path invalidation is exact-match only, so use tags when you need to purge a group of pages.
cache.invalidate() throws if no provider is configured, so only call it in projects where caching is set up.
Using CDN Cache Providers
The memory provider has two limits: each server instance has its own cache, and serverless functions don't live long enough to benefit from it. For those deployments, the official adapters provide CDN providers, which are still marked experimental and must be enabled manually:
| Platform | Import | Headers set |
|---|---|---|
| Netlify | cacheNetlify() from @astrojs/netlify/cache | Netlify-CDN-Cache-Control, Netlify-Cache-Tag |
| Vercel | cacheVercel() from @astrojs/vercel/cache | Vercel-CDN-Cache-Control, Vercel-Cache-Tag |
| Cloudflare | cacheCloudflare() from @astrojs/cloudflare/cache | Cloudflare-CDN-Cache-Control, Cache-Tag |
For example, on Vercel:
// astro.config.mjs
import { defineConfig } from "astro/config";
import vercel from "@astrojs/vercel";
import { cacheVercel } from "@astrojs/vercel/cache";
export default defineConfig({
output: "server",
adapter: vercel(),
cache: {
provider: cacheVercel(),
},
routeRules: {
"/blog/[...slug]": { maxAge: 300, swr: 60 },
},
});
Your route rules and cache.set() calls stay the same. Cache hits are served from the CDN without invoking your function at all, and cache.invalidate() calls the platform's purge API. The Netlify provider needs @astrojs/netlify 8 or later, Vercel needs @astrojs/vercel 11 or later and Cloudflare needs @astrojs/cloudflare 14 or later.
The personalisation warning applies even more strongly here: a CDN shares cached responses between every visitor.
Caching Checklist
- Prerender first: pages that don't change per request should be static, which is faster than any cache.
- Add a provider:
memoryCache()for a single Node server, your adapter's CDN provider for serverless or edge hosting. - Set defaults with routeRules for sections like the blog, docs and public API.
- Tag responses by content type and ID so you can purge precisely.
- Exclude anything personalised from caching entirely.
- Wire up invalidation from your CMS or admin actions.
- Verify in a production build by watching the
X-Astro-Cacheheader or your platform's cache headers.
FAQ: Caching On-Demand Pages in Astro
No. It became stable in Astro 7. Move cache and routeRules out of the experimental block and into the top level of your Astro config. The CDN providers for Netlify, Vercel and Cloudflare are still marked experimental in their adapters.
It is false when no cache provider is configured and always false in development mode, where caching does nothing. Build the project and run the production server to test caching.
maxAge is how long a response is fresh and served directly from the cache. swr is an extra window after that during which the stale response is still served while a fresh one is rendered in the background.
No. Route rules use Astro's routing syntax, so match groups of routes with dynamic or rest parameters such as /api/[...path] instead of * patterns.
Not on a shared URL. Cache keys don't include cookies, so a cached response can be served to a different visitor before your page code runs. Leave personalised routes uncached and load personal content separately.
It works, but each function instance has its own short-lived memory, so hit rates are poor. Use your adapter's CDN cache provider on Netlify, Vercel or Cloudflare instead.
Conclusion
Astro 7's stable route caching gives on-demand pages a clean, platform-agnostic way to be fast. Configure a provider with the top-level cache option, set defaults for whole sections with routeRules, refine individual pages with Astro.cache.set(), and purge by tag or path with cache.invalidate() when content changes. In a quick test with the memory provider, the X-Astro-Cache header made every hit, miss and purge easy to confirm.
The one rule to remember is that caching works by URL. Keep personalised responses out of the cache and load personal details separately, and choose a CDN provider when you deploy to serverless platforms. With those in place, you get fresh data where it matters and static-like speed everywhere else.


