DatoCMS Cache Tags
The DatoCMS Content Delivery API offers a feature called cache tags. It lets you cache your website pages for as long as you like — serving traffic from a CDN instead of querying us on every request, which cuts your hosting bill and your DatoCMS API usage alike — without the usual headache of working out what to invalidate, and when.
Astro is a particularly good place to use it, because since Astro 7 tagged caching is a first-class primitive of the framework rather than something you bolt on. Astro.cache.set({ tags }) records tags for the response being rendered, cache.invalidate({ tags }) purges them, and your adapter's cache provider translates both into whatever your host actually speaks — Cache-Tag on Cloudflare, Netlify-Cache-Tag on Netlify.
So the integration is short: ask the Content Delivery API for the tags behind each query, hand them to Astro.cache, and hand the webhook's tags to cache.invalidate(). Roughly forty lines, most of which you write once in a shared executeQuery wrapper and then never think about again.
This guide is the Astro-specific version of a mechanism documented in full elsewhere. The Cache Tags overview explains it in three steps, Cache tags in CDA responses covers the header format and limits, and the invalidation webhook covers the other direction.
What you'll need
Astro 7 or later, with
output: 'server'. Cache tags only make sense for server-rendered pages: a statically prerendered page has no response for the CDN to tag.An adapter that ships a cache provider — Cloudflare, Netlify and Vercel all do. The examples below use Cloudflare; the last section covers what changes on the other two.
A DatoCMS project on a plan that exposes cache tags, and a read-only CDA token.
Step 1: Enable the cache provider
Point Astro's cache.provider at your adapter's provider:
import cloudflare from '@astrojs/cloudflare';import { cacheCloudflare } from '@astrojs/cloudflare/cache';import { defineConfig } from 'astro/config';
export default defineConfig({ output: 'server', adapter: cloudflare(), cache: { provider: cacheCloudflare(), },});On Cloudflare there's a second switch, and it's the one people miss: the Workers cache has to be turned on in wrangler.jsonc too (with Wrangler 4.69.0 or later). Without it, everything below runs without error and simply never caches anything.
{ "name": "my-site", "main": "@astrojs/cloudflare/entrypoints/server", "compatibility_date": "2026-08-08", "cache": { "enabled": true, },}Astro deliberately disables the cache provider in development mode, so cache.set() does nothing and no headers are emitted. To see cache tags at work you need a production build — astro build && wrangler dev, or a real deploy.
Step 2: Ask DatoCMS for cache tags, and register them
Cache tags are returned in the x-cache-tags response header, but only if you ask for them. With @datocms/cda-client that's the returnCacheTags option — and since you need the header and not just the parsed body, you want rawExecuteQuery, which returns both.
Everything then funnels into a single executeQuery wrapper that every page and component uses:
import { rawExecuteQuery } from '@datocms/cda-client';import { DATOCMS_CDA_TOKEN } from 'astro:env/server';
const ONE_YEAR = 60 * 60 * 24 * 365;
export async function executeQuery(context, query, variables) { const [result, response] = await rawExecuteQuery(query, { token: DATOCMS_CDA_TOKEN, returnCacheTags: true, variables, });
const rawTags = response.headers.get('x-cache-tags'); const cacheTags = rawTags ? rawTags.split(' ') : [];
if (cacheTags.length > 0) { context.cache.set({ maxAge: ONE_YEAR, tags: cacheTags }); }
return result;}Three details worth pausing on:
The header is space-separated. DatoCMS uses spaces (the Fastly
Surrogate-Keyconvention); Cloudflare'sCache-Taguses commas. You split on spaces, hand Astro an array, and the provider re-joins with commas for you.Tags accumulate across the whole request.
Astro.cachekeeps aSetinternally, so callingcache.set()once per query is exactly right: a page that runs a layout query, a header query and a page query ends up with the union of all three tag sets, deduplicated. You never have to collect tags yourself.maxAgeis a year on purpose. The cached response isn't meant to expire on a timer — it's meant to live until the content behind it changes, at which point step 4 purges it explicitly. A TTL here would only mean serving stale content for up to that TTL, and re-fetching content that hasn't changed. If you'd rather have a safety net, aswrwindow is a better tool than a shortmaxAge.
Using it from a page looks like any other data fetch, except you pass Astro through:
---import { executeQuery } from '~/lib/datocms/executeQuery';import { BLOG_POST_QUERY } from './_graphql';
const data = await executeQuery(Astro, BLOG_POST_QUERY, { slug: Astro.params.slug,});---
<h1>{data.blogPost.title}</h1>Step 3: Make sure component-level tags aren't missed
This is the one non-obvious part of the integration, and it's worth understanding rather than just copying.
Astro streams HTML by default. The response object resolves as soon as the page starts rendering, and Astro applies the cache headers at that moment — but queries living inside a Layout, a <Header /> or a <Footer /> haven't run yet. Their tags get registered after the Cache-Tag header has already been serialized, so they're silently dropped. The symptom is nasty precisely because it's partial: the page caches, tag-based purging works for the page's own query, and updating a record that only appears in the footer never purges anything.
The fix is to buffer the HTML in middleware, which forces the full render — and therefore every query — to complete before the headers are applied:
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => { let response = await next();
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html')) { // Astro applies the cache headers as soon as this middleware returns, but // with streaming the response resolves before the body is rendered — so any // executeQuery() inside Layout/Header/Footer would register its cache tags // too late. Buffering the body here forces the whole page to render (and // every query to run) before the tags are serialized. response = new Response(await response.arrayBuffer(), response); }
return response;});You're giving up streaming for HTML responses. On a page that's about to be cached at the edge for a year, that's a very cheap trade: the buffering cost is paid once per cache miss, and every subsequent visitor is served by the CDN anyway.
Step 4: Purge on content change
DatoCMS emits an invalidation webhook listing exactly which tags are now stale. Your endpoint's whole job is to hand those tags to cache.invalidate():
import { CACHE_INVALIDATION_WEBHOOK_SECRET } from 'astro:env/server';
// The webhook delivers a payload shaped like this://// {// entity_type: 'cda_cache_tags',// event_type: 'invalidate',// entity: {// id: 'cda_cache_tags',// type: 'cda_cache_tags',// attributes: { tags: ['8f2a1c', '3b91e7', ...] },// },// }
export const POST = async ({ request, cache }) => { if ( request.headers.get('Authorization') !== `Bearer ${CACHE_INVALIDATION_WEBHOOK_SECRET}` ) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); }
const body = await request.json(); const tags = body.entity.attributes.tags;
if (!tags.length) { return Response.json({ error: 'Missing tags' }, { status: 400 }); }
await cache.invalidate({ tags });
return Response.json({ success: true, invalidated: tags.length });};Then, in your DatoCMS project, go to Settings > Webhooks, create a webhook pointing at https://your-site.com/api/invalidate-cache, add an Authorization: Bearer <your-secret> header, and subscribe it to the Cache tags invalidation event.
That's the entire loop. A record is published, DatoCMS computes which tags are affected, your endpoint purges them, and the next visitor to any page that touched that record gets a fresh render.
Notice that no part of this integration knows which URLs exist, or which pages a record appears on. The tags carry that information implicitly — which is why this keeps working when you add pages, nest components, or restructure your routes.
Verifying it works
Cloudflare reports the cache outcome in Cf-Cache-Status:
curl -sI https://your-site.com/blog/hello-world | grep -i cf-cache-status# cf-cache-status: MISS ← first request# cf-cache-status: HIT ← second request, served without reaching your WorkerNow edit that record in DatoCMS and publish it. The next request should be a MISS again, with the new content — that transition is the whole integration working.
When it isn't:
No
Cf-Cache-Statusat all — Workers Cache isn't active:cache.enabledis missing fromwrangler.jsonc, or Wrangler is older than 4.69.0.A
HITthat survives publishing — look at the webhook, not the caching. DatoCMS logs every delivery along with the response your endpoint returned, which separates "never fired" from "returned a 401".
Don't go looking for the tags themselves: Cloudflare strips Cache-Tag before the response leaves the edge. To see what your app registered, echo Astro.cache.tags from the middleware, after the buffering step:
// src/middleware.js — temporary, for debugging onlyresponse.headers.set('X-Debug-Cache-Tags', context.cache.tags.join(','));Things to keep in mind
Draft mode must not be cached. If you've set up draft mode, draft responses are per-editor and must never reach a shared cache. The simplest guard is to skip tag registration entirely when drafts are on:
if (!includeDrafts && cacheTags.length > 0) { context.cache.set({ maxAge: ONE_YEAR, tags: cacheTags });}Astro also gives you Astro.cache.set(false), which disables caching for the current response outright — useful as a belt-and-braces measure in a middleware that already knows whether draft mode is active. Either way, pair it with an explicit Cache-Control: private, no-store on draft responses.
Deploys don't invalidate anything. Cache tags track content changes, not code changes. Ship a template tweak and the CDN will happily keep serving the old HTML for a year. Add a full purge to your deploy pipeline — on Cloudflare, an authenticated route is the easiest way:
import { cache } from 'cloudflare:workers';
await cache.purge({ purgeEverything: true });Mind the tag budget. A Content Delivery API response can carry up to 500 cache tags, and every query on a page contributes to the same set. Cloudflare has room to spare; other hosts don't (see below). A page that genuinely needs hundreds of tags is usually a page worth rethinking anyway — paginate the listing, or narrow the query.
Deploying on Netlify or Vercel
The code doesn't change. Swap the adapter and its provider in astro.config.mjs and everything else — executeQuery, the middleware buffering, the webhook endpoint — stays exactly as written:
// Netlifyimport netlify from '@astrojs/netlify';import { cacheNetlify } from '@astrojs/netlify/cache';export default defineConfig({ output: 'server', adapter: netlify(), cache: { provider: cacheNetlify() },});
// Vercelimport vercel from '@astrojs/vercel';import { cacheVercel } from '@astrojs/vercel/cache';export default defineConfig({ output: 'server', adapter: vercel(), cache: { provider: cacheVercel() },});What differs is underneath, and one difference is worth knowing before you commit:
Cloudflare — tags go out as
Cache-Tag, purging goes through the Workerscache.purge()API. The header holds 16 KB, roughly 1,000 tags.Netlify — tags go out as
Netlify-Cache-Tag, purging throughpurgeCache()from@netlify/functions. The cap is 500 tags per response, exactly matching ours.Vercel — tags go out as
Vercel-Cache-Tag, purging throughinvalidateByTag()from@vercel/functions. The cap is 128 tags per response.
Cloudflare and Netlify comfortably fit everything DatoCMS can return. Vercel caps a cached response at 128 tags, well below our 500, so a page whose queries collectively touch more than 128 records will overflow the budget. Most detail pages are nowhere near that ceiling; large listing pages can cross it without anyone noticing. Vercel doesn't document what happens to the excess, so if you're on Vercel with tag-heavy pages, verify the behaviour before relying on it.
Vercel's purge path is also chattier: the provider issues one invalidateByTag() call per tag, so a webhook delivery carrying a few hundred tags becomes a few hundred concurrent API calls inside one function invocation. If you're on Vercel and publishing in bulk, chunk the tags in your webhook handler rather than passing the whole array through at once.
The 128-tag ceiling belongs to Vercel's CDN, not to any framework. What Astro saves you on Vercel is the plumbing — tags still travel straight from our response header to Vercel-Cache-Tag, with no mapping layer in between — but the budget is the platform's, and it applies either way.