Skip to content

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.


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

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.

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 on API-Key, Authorization, Token and Token-User. Your Cloudflare Cache Rule for the API host must pass these headers through.
  • Publication content listings (JSON and RSS) carry a Cache-Tag header. 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-Tag for 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.

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:

  1. The Cloudflare API purge runs first, clearing the CDN cache.
  2. The webhook is called next, clearing your frontend cache.

  1. Navigate to Publisher Configuration -> System Settings.
  2. Scroll to the Caching section.
  3. Fill in the fields described below.
  4. 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.


For every purge, the News Suite sends a request like this to your Webhook purge URL:

POST /api/cache/invalidate HTTP/1.1
Host: www.mydomain.com
Content-Type: application/json
Authorization: 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.
  • urls contains full absolute URLs. If your publications use different domains, one request can contain URLs from more than one domain.
  • tags contains 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:

  1. Check that the Authorization header equals Bearer <webhook secret>, and return 401 if it doesn’t.
  2. Clear each URL in urls from its cache.
  3. Clear every cache entry tagged with one of the tags.
  4. Respond with a 2xx status.

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.


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.

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.

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;
});

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>;
};
}

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.

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 template
import { 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}`] });
}
---

If your frontend is a plain Cloudflare Worker that caches pages in Workers Cache, use the same approach yourself:

  1. Add a Cache-Tag header with a tag per path to every page you cache, plus any News Suite tags for the API responses the page was built from.
  2. 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;
},
};

Send a test request to your endpoint with curl:

Terminal window
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:

  1. The response status is 2xx.
  2. The same request with a wrong secret returns 401.
  3. 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:

Terminal window
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.

  • 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 curl test 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.

  • 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.