Caching and cache purging
This guide explains the caching layers available in the WhiteBeard News Suite, how to configure them in the backend, and how to set up your frontend to receive purge requests when content changes.
Caching layers
Section titled “Caching layers”The News Suite supports three caching layers. You can use any combination of them.
| Layer | Where it lives | What it caches | How it is purged |
|---|---|---|---|
| Internal content cache | News Suite servers | Serialized article payloads used by the API | Automatically, whenever an article is saved |
| CDN cache (Cloudflare API) | Cloudflare, in front of the API and/or your frontend | API responses and frontend pages | The News Suite calls the Cloudflare purge API by URL, prefix and Cache-Tag |
| Frontend cache (webhook) | Your frontend, e.g. Cloudflare Workers Cache or Astro Cache | Rendered frontend pages | The News Suite sends a purge webhook with the frontend URLs and Cache-Tags to clear |
Internal content cache
Section titled “Internal content cache”The API keeps a server-side cache of each article’s API payload, so that listings and article requests don’t rebuild every article from the database. It is cleared automatically when an article changes, and needs no configuration.
You can turn it off with Disable internal content cache (see Backend setup). Only do this temporarily, for example during a content migration, because it increases database load.
CDN cache (Cloudflare API)
Section titled “CDN cache (Cloudflare API)”API responses are sent with cache headers so a CDN can store them:
- Most public content endpoints respond with
Cache-Control: public, max-age=3600. Some endpoints set their own lifetime, such as sitemaps and archives. - Every cacheable response includes
Vary: API-Key, because API keys linked to backend users can see unpublished items. Your CDN must keep a separate copy per API key. - Per-customer responses are cached at the edge for a short time (
s-maxage=300) and vary onAPI-Key,Authorization,TokenandToken-User. Your Cloudflare Cache Rule for the API host must pass these headers through. - Publication content listings (JSON and RSS) carry a
Cache-Tagheader. This lets a single purge clear every page and query-string variant of a listing. - Page builder responses for category, author, keyword and article pages carry a
Cache-Tagfor the template they use. When the template is published or retargeted, one purge clears every page using it.
When content is published or updated, the News Suite purges the affected entries through the Cloudflare API, using three methods:
- URLs: the content’s frontend URLs, the related category, author and keyword pages, and the publication’s sitemaps and archive day
- Prefixes: API paths such as
/cms/content/{id}and/cms/category/{id}/content - Cache-Tags: the publication’s content listings
If the content is scheduled for a future publish date, the purge is scheduled to run at that time.
Frontend cache (webhook)
Section titled “Frontend cache (webhook)”If your frontend keeps its own cache of rendered pages, the CDN purge alone won’t clear it. On the next request the CDN would fetch the stale page from your frontend’s cache and store it again.
To handle this, the News Suite can call a purge webhook on your frontend. It sends the frontend URLs that changed, and the Cache-Tags of the API responses that changed, so your frontend can clear them from its own cache. This works with Cloudflare Workers Cache, Astro Cache, or any custom cache you control.
The webhook doesn’t replace the Cloudflare API purge; when both are configured, both run:
- The Cloudflare API purge runs first, clearing the CDN cache.
- The webhook is called next, clearing your frontend cache.
Backend setup
Section titled “Backend setup”- Navigate to Publisher Configuration -> System Settings.
- Scroll to the Caching section.
- Fill in the fields described below.
- Click on Update settings.
| Field | Description |
|---|---|
| Disable internal content cache | Turns off the internal content cache. Leave unticked unless you are running a migration. |
| Enable CDN Cache purging | Turns on all purging: both the Cloudflare API purge and the webhook purge. Nothing is purged while this is unticked. |
| API Token | A Cloudflare API token with the Zone -> Cache Purge -> Purge permission for your zone. |
| Zone ID | The ID of the Cloudflare zone to purge. You can find it on the zone’s Overview page in Cloudflare. |
| Purge URLs | Extra URLs to purge with every content publish, one per line, up to 20. Useful for pages such as your homepage. |
| Webhook purge URL | The full URL of your frontend’s purge endpoint, e.g. https://www.mydomain.com/api/cache/invalidate. |
| Webhook secret | A shared secret sent with every webhook request. Your endpoint uses it to reject requests that don’t come from the News Suite. |
Each purge method runs only if its own fields are filled in:
- Cloudflare API purge needs the API Token and Zone ID.
- Webhook purge needs the Webhook purge URL and Webhook secret.
You can configure either one or both.
Webhook request format
Section titled “Webhook request format”For every purge, the News Suite sends a request like this to your Webhook purge URL:
POST /api/cache/invalidate HTTP/1.1Host: www.mydomain.comContent-Type: application/jsonAuthorization: Bearer <webhook secret>
{ "urls": [ "https://www.mydomain.com/article/123-some-slug", "https://www.mydomain.com/news", "https://www.mydomain.com/author/jane-doe" ], "tags": [ "pblayout-mysite-12" ]}- The URL is called exactly as entered in Webhook purge URL.
urlscontains full absolute URLs. If your publications use different domains, one request can contain URLs from more than one domain.tagscontains the Cache-Tags of the API responses that changed (see Cache tags).- Either list can be empty.
- A request contains at most 100 URLs. Larger purges are sent as several requests.
- Only frontend URLs are sent. URLs on the API hostnames (API Endpoint hostname and API Endpoint hostname (Secondary)) are left out, and so are API prefixes.
- The request times out after 15 seconds.
Your endpoint must:
- Check that the
Authorizationheader equalsBearer <webhook secret>, and return401if it doesn’t. - Clear each URL in
urlsfrom its cache. - Clear every cache entry tagged with one of the
tags. - Respond with a 2xx status.
Cache tags
Section titled “Cache tags”Tags let your frontend clear every page built from an API response, whatever its URL. For example, when editors publish a page builder template, the News Suite can’t list every category page that uses it, but it can send the template’s tag.
Tags follow a fixed pattern. {site} is the News Suite deployment name: ask the WhiteBeard team for it, and keep it in your frontend config (for example a CMS_SITE environment variable).
| Tag | Put it on | Purged when |
|---|---|---|
pblayout-{site}-{layoutId} |
Pages built from a category, author, keyword or content /layout response whose layout is not null. layoutId is layout.id in the response |
The template is published, renamed, disabled, deleted or retargeted |
pubcontent-{site}-{publicationId} |
Pages built from that publication’s content listings | The publication’s content changes |
Build the tags yourself. The API sets them in a Cache-Tag response header, but Cloudflare removes that header before the response reaches your frontend, so you can’t read it.
Ignore tags you don’t recognise. New tags may be added in the future.
Frontend setup
Section titled “Frontend setup”Both examples below read the webhook secret from an environment variable called CACHE_PURGE_SECRET. Set it to the same value as Webhook secret in the backend, with wrangler secret put CACHE_PURGE_SECRET.
Both also compare the secret in constant time. A plain !== comparison stops at the first character that differs, so response times can leak how much of a guess was right. Hashing both values first and comparing every byte avoids that.
Astro Cache
Section titled “Astro Cache”With the @astrojs/cloudflare adapter, Astro 7 caches rendered routes in Cloudflare’s Workers Cache. Every cached page gets a Cache-Tag of astro-path:<pathname>. Your purge endpoint turns each URL it receives into that tag, adds the News Suite tags it receives, and purges them all with cache.purge() from cloudflare:workers. This clears exactly the pages that changed, including their query-string variants.
1. Enable route caching
Section titled “1. Enable route caching”In astro.config.mjs, set the Cloudflare cache provider and declare the purge secret:
import { defineConfig, envField } from 'astro/config';import cloudflare from '@astrojs/cloudflare';import { cacheCloudflare } from '@astrojs/cloudflare/cache';
export default defineConfig({ output: 'server', adapter: cloudflare(), cache: { provider: cacheCloudflare(), }, env: { schema: { CACHE_PURGE_SECRET: envField.string({ context: 'server', access: 'secret', optional: true }), }, },});Pages are only cached when they’re given cache options. Set a default in src/middleware.ts, and skip API routes so the purge endpoint is never cached:
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => { if (context.url.pathname.startsWith('/api/') || context.request.method !== 'GET') { return next(); }
// Pages can override this with Astro.cache.set() context.cache.set({ maxAge: 3600, swr: 360 });
const response = await next(); if (response.status !== 200 || response.headers.has('set-cookie')) { context.cache.set(false); } return response;});2. Add types for cloudflare:workers
Section titled “2. Add types for cloudflare:workers”The endpoint imports cache from cloudflare:workers. If your project doesn’t load the full Workers runtime types (generated with wrangler types), declare the part it uses in src/cloudflare-workers.d.ts:
declare module 'cloudflare:workers' { interface CachePurgeOptions { tags?: string[]; pathPrefixes?: string[]; purgeEverything?: boolean; }
interface CachePurgeResult { success: boolean; errors: { code: number; message: string }[]; }
export const cache: { purge(options: CachePurgeOptions): Promise<CachePurgeResult>; };}3. Create the purge endpoint
Section titled “3. Create the purge endpoint”Create src/pages/api/cache/invalidate.ts:
import type { APIRoute } from 'astro';import { CACHE_PURGE_SECRET } from 'astro:env/server';import { pathTag } from 'astro/cache/provider-utils';import { cache } from 'cloudflare:workers';
export const prerender = false;
/** Constant-time string comparison (hashing first makes the lengths equal). */async function secretsMatch(a: string, b: string): Promise<boolean> { const encoder = new TextEncoder(); const [hashA, hashB] = await Promise.all([ crypto.subtle.digest('SHA-256', encoder.encode(a)), crypto.subtle.digest('SHA-256', encoder.encode(b)), ]); const viewA = new Uint8Array(hashA); const viewB = new Uint8Array(hashB); let diff = 0; for (let i = 0; i < viewA.length; i++) diff |= viewA[i] ^ viewB[i]; return diff === 0;}
export const POST: APIRoute = async ({ request }) => { if (!CACHE_PURGE_SECRET) { return Response.json({ error: 'Cache purging is not configured' }, { status: 503 }); }
const authHeader = request.headers.get('Authorization') ?? ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; if (!token || !(await secretsMatch(token, CACHE_PURGE_SECRET))) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); }
let body: { urls?: unknown; tags?: unknown }; try { body = (await request.json()) ?? {}; } catch { return Response.json({ error: 'Body must be JSON' }, { status: 400 }); } const urls = Array.isArray(body.urls) ? body.urls : []; const cmsTags = Array.isArray(body.tags) ? body.tags : [];
const tags = new Set<string>(); for (const url of urls) { try { // The encoded pathname, as the cache provider uses it in its path tags tags.add(pathTag(new URL(String(url)).pathname)); } catch { // Ignore malformed URLs } } // News Suite tags are purged as they are; pages carry them via Astro.cache.set({ tags }) for (const tag of cmsTags) { if (typeof tag === 'string' && tag) tags.add(tag); }
if (tags.size === 0) { return Response.json({ ok: true, tags: [] }); }
try { const result = await cache.purge({ tags: [...tags] }); if (!result.success) { console.error('Cache purge rejected:', result.errors); return Response.json({ error: 'Cache purge rejected', errors: result.errors }, { status: 502 }); } } catch (e) { console.error('Cache purge failed:', e); return Response.json({ error: 'Cache purge failed' }, { status: 500 }); }
return Response.json({ ok: true, tags: [...tags] });};Then set Webhook purge URL to https://www.mydomain.com/api/cache/invalidate.
4. Tag pages with News Suite tags
Section titled “4. Tag pages with News Suite tags”For tag purges to reach a page, the page must carry the tag. Add it with Astro.cache.set() in pages built from a tagged API response (see Cache tags). Tags from several set() calls are combined, so the middleware’s defaults and the path tag are kept:
---// e.g. src/pages/[category].astro, rendered from a page builder templateimport { getCategoryLayout } from '../lib/pagebuilder/api';
const res = await getCategoryLayout(Astro.params.category!);if (res?.layout) { Astro.cache.set({ tags: [`pblayout-${import.meta.env.CMS_SITE}-${res.layout.id}`] });}---Cloudflare Workers Cache
Section titled “Cloudflare Workers Cache”If your frontend is a plain Cloudflare Worker that caches pages in Workers Cache, use the same approach yourself:
- Add a
Cache-Tagheader with a tag per path to every page you cache, plus any News Suite tags for the API responses the page was built from. - In the purge route, turn each URL into the same path tag, add the News Suite tags as they are, and purge with
ctx.cache.purge({ tags }).
const pathTag = (pathname: string) => `path:${pathname}`;
/** Constant-time string comparison (hashing first makes the lengths equal, as timingSafeEqual requires). */async function secretsMatch(a: string, b: string): Promise<boolean> { const encoder = new TextEncoder(); const [hashA, hashB] = await Promise.all([ crypto.subtle.digest('SHA-256', encoder.encode(a)), crypto.subtle.digest('SHA-256', encoder.encode(b)), ]); return crypto.subtle.timingSafeEqual(hashA, hashB);}
export default { async fetch(request: Request, env: { CACHE_PURGE_SECRET?: string }, ctx: ExecutionContext): Promise<Response> { const url = new URL(request.url);
if (url.pathname === '/api/cache/invalidate' && request.method === 'POST') { if (!env.CACHE_PURGE_SECRET) { return Response.json({ error: 'Cache purging is not configured' }, { status: 503 }); }
const authHeader = request.headers.get('Authorization') ?? ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; if (!token || !(await secretsMatch(token, env.CACHE_PURGE_SECRET))) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } if (!ctx.cache) { return Response.json({ error: 'Workers Cache is not available' }, { status: 500 }); }
const body = (await request.json().catch(() => ({}))) as { urls?: unknown; tags?: unknown }; const urls = Array.isArray(body.urls) ? body.urls : []; const cmsTags = Array.isArray(body.tags) ? body.tags : [];
const tags = new Set<string>(); for (const u of urls) { try { tags.add(pathTag(new URL(String(u)).pathname)); } catch { // Ignore malformed URLs } } for (const tag of cmsTags) { if (typeof tag === 'string' && tag) tags.add(tag); }
if (tags.size > 0) { try { const result = await ctx.cache.purge({ tags: [...tags] }); if (!result.success) { return Response.json({ error: 'Cache purge rejected', errors: result.errors }, { status: 502 }); } } catch (e) { console.error('Cache purge failed:', e); return Response.json({ error: 'Cache purge failed' }, { status: 500 }); } }
return Response.json({ ok: true, tags: [...tags] }); }
// ... your normal page rendering; `cmsTags` are the News Suite tags of the API responses it used const { response, cmsTags } = await renderPage(request);
// Tag every cached page with its path, so the purge route can clear it exactly const tagged = new Response(response.body, response); tagged.headers.set('Cache-Tag', [pathTag(url.pathname), ...cmsTags].join(',')); return tagged; },};Testing your setup
Section titled “Testing your setup”Send a test request to your endpoint with curl:
curl -i -X POST https://www.mydomain.com/api/cache/invalidate \ -H "Authorization: Bearer <webhook secret>" \ -H "Content-Type: application/json" \ -d '{"urls": ["https://www.mydomain.com/"]}'Check that:
- The response status is 2xx.
- The same request with a wrong secret returns 401.
- After the purge, the next request to
https://www.mydomain.com/is a cache miss.
To test tag purges, send a tag instead, for example the tag of a page builder template one of your pages uses:
curl -i -X POST https://www.mydomain.com/api/cache/invalidate \ -H "Authorization: Bearer <webhook secret>" \ -H "Content-Type: application/json" \ -d '{"urls": [], "tags": ["pblayout-mysite-12"]}'Every page tagged with it should be a cache miss on its next request.
Then publish an article in the backend and confirm that its page updates on your frontend.
Troubleshooting
Section titled “Troubleshooting”- Nothing is purged: check that Enable CDN Cache purging is ticked, and that both webhook fields are filled in.
- The purge job fails: your endpoint returned a non-2xx status or couldn’t be reached within 15 seconds. Run the
curltest above. - Template changes don’t show up: the pages built from the template don’t carry its
pblayout-…tag, or your{site}value differs from the deployment name. See Cache tags. - The endpoint succeeds, but pages stay stale: the URLs the News Suite sends don’t match your cache keys. Article URLs use the publication’s Domain if one is set, otherwise the site’s main domain. Make sure these match the domain your frontend serves, including trailing slashes.
- No URLs reach the webhook: your frontend may be on the same hostname as the API Endpoint hostname. URLs on the API hostnames are never sent to the webhook.
Summary
Section titled “Summary”- The internal content cache needs no setup and clears itself.
- The Cloudflare API purge needs an API token and zone ID. It purges URLs, API prefixes and Cache-Tags.
- The webhook purge needs a full URL and a secret. It sends
{"urls": [...], "tags": [...]}with a Bearer token to your frontend, which clears those URLs and every entry tagged with those tags, and responds with a 2xx status.