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

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, 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"
]
}
  • 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.
  • 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 prefixes and Cache-Tags.
  • 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. Respond with a 2xx status.

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, and purges the tags 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 urls: unknown;
try {
urls = (await request.json())?.urls;
} catch {
return Response.json({ error: 'Body must be JSON' }, { status: 400 });
}
if (!Array.isArray(urls)) {
return Response.json({ error: '`urls` must be an array' }, { status: 400 });
}
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
}
}
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.

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.
  2. In the purge route, turn each URL into the same tag 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 };
const urls = Array.isArray(body.urls) ? body.urls : [];
const tags = new Set<string>();
for (const u of urls) {
try {
tags.add(pathTag(new URL(String(u)).pathname));
} catch {
// Ignore malformed URLs
}
}
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
const response = 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));
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.

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.
  • 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": [...]} with a Bearer token to your frontend, which clears its own cache and responds with a 2xx status.