# Route Caching

> Cache rendered pages with Astro route caching and let EmDash invalidate them by tag when content changes.

Source: https://emdashcmseverything.com/official-docs/deployment/route-caching/

[Astro's route caching](https://docs.astro.build/en/guides/caching/) stores the rendered response of an on-demand page, so a cache hit skips rendering. EmDash supplies cache tags for the content a page renders and invalidates those tags when the content changes. Pages can stay cached for days while a publish usually becomes visible within seconds.

The [object cache](https://emdashcmseverything.com/official-docs/deployment/object-cache/) stores query results and still renders every page. With a cache provider configured, anonymous renders skip the object cache and read from the database, so a render that refills an invalidated page doesn't come from an older snapshot (see [What gets cached](https://emdashcmseverything.com/official-docs/deployment/object-cache/#what-gets-cached)). The route cache then serves anonymous visitors, and the object cache mainly speeds up renders for signed-in editors.

## Choose a cache provider

Route caching needs a cache provider. Use the one that matches your deployment:

| Deployment                            | Provider            | Import from                 |
| ------------------------------------- | ------------------- | --------------------------- |
| [Node.js](https://emdashcmseverything.com/official-docs/deployment/nodejs/)        | `memoryCache()`     | `astro/config`              |
| [Cloudflare](https://emdashcmseverything.com/official-docs/deployment/cloudflare/) | `cacheCloudflare()` | `@astrojs/cloudflare/cache` |

`memoryCache()` keeps entries in the memory of the server process. `cacheCloudflare()` is a CDN provider: it stores responses in the Cloudflare Workers Cache, and a cache hit never runs the Worker. Astro's Netlify and Vercel adapters ship CDN providers as well; see [Adapter cache providers](https://docs.astro.build/en/guides/caching/#adapter-cache-providers). EmDash invalidates through Astro's provider-independent `cache.invalidate()` API, so its tags work with any provider. [Provider differences](#provider-differences) covers the behavior that depends on the provider. `astro dev` never caches, so test caching against a production build.

## Configure route caching

Register the provider and declare which routes may be cached, for how long, and under which tags. This example uses the Cloudflare provider; on Node, use `cache: { provider: memoryCache() }` with `memoryCache` imported from `astro/config`:

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import { cacheCloudflare } from "@astrojs/cloudflare/cache";

export default defineConfig({
	// ...your existing output, adapter, and integrations
	cache: {
		provider: cacheCloudflare(),
	},
	routeRules: {
		"/": { maxAge: 86_400, swr: 604_800, tags: ["posts", "pages", "all"] },
		"/blog": { maxAge: 86_400, swr: 604_800, tags: ["posts", "all"] },
		"/blog/[slug]": { maxAge: 86_400, swr: 604_800, tags: ["posts", "all"] },
		"/sitemap.xml": { maxAge: 86_400, swr: 604_800, tags: ["posts", "pages", "all"] },
		"/sitemap-[collection].xml": { maxAge: 86_400, swr: 604_800, tags: ["posts", "pages", "all"] },
	},
});
```

- `maxAge` is how long the cache serves the entry before the route renders again. EmDash invalidates stale entries on publish (below), but an invalidation can fail, so `maxAge` also bounds how long a page can stay stale, plus one stale response while `swr` revalidates it. Check [Provider differences](#provider-differences) before choosing a long value.
- `swr` lets the cache keep serving a stale entry while it revalidates in the background, so visitors rarely wait on a render once a page is cached.
- `tags` name the entry for invalidation. Tag each route with the collections it renders, using the collection names: EmDash's built-in invalidation uses collection names, so matching tags are invalidated automatically. A broad extra tag on every cached route (here `all`, which is not a collection name, so EmDash never invalidates it on its own) works as an emergency switch: invalidating it, for example with `context.cache.invalidate({ tags: ["all"] })` in an endpoint you protect yourself, clears everything at once.

The query helpers also return a `cacheHint` you can pass to `Astro.cache.set()` inside the page. Its tags name the exact entry (`getEmDashEntry`), the collection (`getEmDashCollection`), or the chrome resource (`getMenuWithCacheHint`, `getSiteSettingsWithCacheHint`, `getTaxonomyTermsWithCacheHint`, `getWidgetAreaWithCacheHint`). These are the same tags EmDash invalidates on writes, so a page that passes its hints is invalidated when an entry or chrome resource it renders is written, including ones its route rule doesn't name:

```astro title="src/pages/blog/[slug].astro"
---
import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;
if (!slug) return Astro.rewrite("/404");

const { entry, cacheHint } = await getEmDashEntry("posts", slug);
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
```

`Astro.cache.set()` merges with the route rule, and tags accumulate across calls. The cache hints carry tags but no `maxAge`, so the route rule decides how long a page is cached.

Give every public route an explicit rule. Rules match the request path, so list your public routes individually: a rest parameter at the site root, such as `/[...slug]`, also matches the admin and API routes under `/_emdash/`.

## Invalidation on publish is built-in

EmDash's content API routes invalidate the route cache whenever live content changes: publishing, unpublishing, deleting, or restoring an entry from the trash, and saving changes that reach the live entry, invalidate the tags `[collection, id]`. Creating or duplicating an entry invalidates nothing, because the new entry is a draft. Any route rule tagged with the collection name (as above) is therefore usually refreshed within seconds of a publish, even with a long `maxAge`.

Site chrome is covered too: saving site settings invalidates `emdash:settings`, menu edits invalidate `emdash:menu:{name}`, and taxonomy and widget-area writes invalidate their matching `emdash:taxonomy:{name}` / `emdash:widget-area:{name}` tags. Pages pick these tags up automatically when they use the `cacheHint` from `getMenuWithCacheHint()`, `getSiteSettingsWithCacheHint()`, `getTaxonomyTermsWithCacheHint()`, or `getWidgetAreaWithCacheHint()`.

Menu tags cover menu edits only. Publishing, unpublishing, trashing, or restoring an entry that a menu links to, or changing the slug of a linked entry or taxonomy term, doesn't invalidate the menu's tag. A page that shows the menu picks up such a change when its tags also name that entry, its collection, or that term's taxonomy, and otherwise when the page expires.

Creating, editing, or deleting a redirect invalidates the cached page at the redirect's source path, with and without a trailing slash, so the redirect applies on the next request.

Restoring an older revision, and changes to bylines, the media library, and comments, invalidate nothing. Cached pages affected by them update once they expire or the entry that renders them is saved again. Scheduled publishing invalidates the published entries' tags on Cloudflare only; see [Provider differences](#provider-differences).

This is also why the tag convention matters: EmDash invalidates collection names, entry ids, and the `emdash:*` chrome tags. If your route rules use other tag names, the built-in invalidation will not match them and stale entries survive until `maxAge` expires.

## Keep responses out of the cache

Route rules match the request path, so they also apply to responses you do not want cached under a long TTL. EmDash keeps responses rendered for a signed-in user, and responses sent with `Cache-Control: private` or `no-store`, out of the route cache automatically, and 404 responses too. Other error responses and your own middleware redirects are the template's job, opted out per request with `Astro.cache.set(false)`.

`Astro.cache.set(false)` only holds until the next `Astro.cache.set()` call. A layout renders after the page frontmatter, so a layout that sets a cache hint turns caching back on for a page that opted out. Pass the opt-out to the layout (for example as a prop) and skip its `Astro.cache.set()` calls when it is set.

### Preview and edit links are handled automatically

EmDash's middleware opts these responses out of the route cache itself, so no template code is needed: requests carrying a `?_preview=` token (valid or not, because those URLs are per-token and a cached draft would otherwise stay reachable after the token expires), and responses with the editor toolbar injected. With [`toolbar: "client"`](https://emdashcmseverything.com/official-docs/reference/configuration/#toolbar), the same applies to `?_edit` URLs. Valid preview responses and toolbar-injected responses are additionally marked `Cache-Control: private, no-store` for browsers and downstream CDNs.

### Error responses

EmDash keeps every 404 out of the route cache, so publishing a missing entry later shows it right away. Other error responses, such as a 500 rendered on a cached route, inherit the rule's TTL. Opt them out with `Astro.cache.set(false)`.

### Redirects in middleware

Middleware that short-circuits with a redirect (for example, host canonicalization) must opt out with `context.cache?.set(false)`. Otherwise the redirect is cached under the route rule's TTL, and on a CDN provider that doesn't separate cache entries by hostname it can be served to requests for the canonical hostname too, causing an instant redirect loop. Use optional chaining: `context.cache` is undefined on some responses, such as the 404 page for a URL that matches no route.

## Editors and cached pages

The route cache stores one shared variant per URL, and its cache key ignores cookies, so a cache hit never reflects an editor's session. With the default server-rendered toolbar, an editor browsing the public site is usually served the cached anonymous variant, without the toolbar, and the `emdash-edit-mode` cookie has no effect on a cached URL. A page rendered for a signed-in editor is never stored in the shared cache; the next anonymous visitor fills it.

Set [`toolbar: "client"`](https://emdashcmseverything.com/official-docs/reference/configuration/#toolbar) for sites that use route caching. A small script then shows the edit pill to signed-in editors on cached pages, and the edit view loads from an `?_edit` URL that is always rendered fresh. Preview links bypass the cache in either mode.

## Provider differences

### Node.js with `memoryCache()`

- Each server process keeps its own cache, and an invalidation only clears the process that handled the write. If you run several processes or instances, the others keep serving their cached pages until `maxAge` expires.
- A restart empties the cache.
- `memoryCache()` only stores a response that has a `maxAge`, so a route without a rule is never cached, even if the page calls `Astro.cache.set()` with a cache hint.
- Scheduled publishing does not invalidate the route cache on Node. A page that lists a scheduled entry shows it once its cached copy expires, so keep `maxAge` short on routes that list scheduled content.

### Cloudflare with `cacheCloudflare()`

- The provider writes `Cloudflare-CDN-Cache-Control` and `Cache-Tag` response headers. The Workers Cache consumes both; clients never see them, and any `Cache-Control` header your pages set for browsers is unaffected.
- When a cache provider is configured, the `@astrojs/cloudflare` adapter enables the Workers Cache automatically: the build writes `"cache": { "enabled": true }` into the generated Wrangler configuration that `wrangler deploy` picks up.
- A route without a rule is marked `no-store`, but only if nothing calls `Astro.cache.set()` while it renders. A cache hint without `maxAge`, like the ones from `getSiteSettingsWithCacheHint()` and `getMenuWithCacheHint()`, produces `Cloudflare-CDN-Cache-Control: public` without a lifetime, so the Workers Cache falls back to its [default cache lifetimes](https://developers.cloudflare.com/workers/cache/configuration/#cache-control-semantics) (2 hours for a `200` response). If your layout sets these hints, every page that uses the layout becomes cacheable, with or without a rule.
- By default the Worker version is part of the cache key, so every deploy starts with an empty cache and the first requests after it render in the Worker. To keep entries across deploys, set `"cache": { "enabled": true, "cross_version_cache": true }` in your Wrangler configuration (see [Cross-version caching](https://developers.cloudflare.com/workers/cache/configuration/#cross-version-caching)). Template changes then only show once the affected entries expire or are invalidated.
- Scheduled publishing invalidates the tags of the entries it publishes from the Cron Trigger.

#### Purge rate limits

The Cloudflare provider invalidates by purging the Workers Cache. Workers Cache purges always use Cloudflare's [Free plan purge limits](https://developers.cloudflare.com/cache/how-to/purge-cache/#availability-and-limits), whatever plan your account is on: 5 requests per minute with a burst of 25, per account (see [Purge rate limits](https://developers.cloudflare.com/workers/cache/purge/#rate-limits)). Most content writes cost one purge request each: saves that change live content (including autosaves in collections that save directly to the live entry), publishing, unpublishing, deleting, restoring, scheduling, and assigning taxonomy terms to an entry, plus every settings, menu, taxonomy, or widget edit. A redirect write costs one purge request per source path variant, so up to four when a redirect's source changes.

A rate-limited purge fails without an error in the admin, and the affected pages keep their cached version until `maxAge` expires. During bulk edits, or with several editors working at once, choose a `maxAge` you can accept as the worst case for stale content.

## Browser caching is separate

The route cache can afford long TTLs because EmDash invalidates it; browser caches are not invalidated. EmDash hints supply purge tags, not automatic `Last-Modified` validators or Astro's implicit `Cache-Control: no-cache` header. One entry's modification time does not cover settings, related lists, or deleted entries, so using it as a page validator can keep an outdated body through conditional `304` responses.

Set browser caching with `Astro.response.headers.set("Cache-Control", ...)`: use `no-cache` for a fresh check on each visit, or a short lifetime (minutes, not days). To enable conditional requests, supply your own `lastModified` hint whose timestamp advances whenever any dependency of the response changes.

For an entry's displayed modification date, use `entry.data.updatedAt` rather than `cacheHint.lastModified`, which EmDash does not populate automatically.
