Tenant-Aware Cache Invalidation for Multi-Site Headless Frontends

This guide belongs to Multi-Tenant Architecture Patterns and solves a problem that single-site setups never face: when content is published, purge exactly the caches of the tenant it belongs to, and nothing of any other tenant. It covers tenant-prefixed cache tags, routing webhooks to the right tenant, content shared between tenants and protecting shared purge quotas.

On a single site, tagging cached responses with entry ids and purging those tags on publish is enough. With many tenants on one frontend and one CDN, three things change. Entry ids can collide across CMS spaces or instances, so a tag like entry:42 may name different entries for different tenants. Webhooks arrive from many spaces and must be attributed to the right tenant reliably. And one tenant’s bulk publish can consume the CDN’s purge rate limit for everyone.

From publish to a purge scoped to one tenantA publish in acme's CMS space sends a webhook signed with acme's secret; the handler identifies the tenant from the secret that verified the signature, builds tags prefixed with acme, and purges only acme's cached responses at the CDN and in the data cache.Publish inacme spaceWebhook handlertry tenant secretsTagsacme:entry:42CDN purgeacme tags onlyData cacherevalidateTagsignedtenant = acme
The tenant comes from the verified secret, never from anything the payload claims.

The Problem

An agency’s shared frontend served 30 client sites, each with its own CMS space. Cache tags used plain entry ids. When one client published an update to entry 5KsDBWseXY6QegucYAoacS, the purge also removed a cached page for another client whose space happened to contain an entry with the same id, copied from a shared starter template. That was harmless but wasteful. The real incident came later: a webhook handler read the tenant from a custom header configured in each space’s webhook settings, and when one client’s webhook was misconfigured with another client’s header value, every publish by the first client purged the second client’s caches, while the first client’s own pages stayed stale for a day.

How Tenant-Aware Invalidation Works

Prefix every tag with the tenant id. Responses are tagged acme:entry:42, acme:type:article, acme:nav. Purges for one tenant can never match another tenant’s tags, whatever the entry ids are.

Identify the tenant from the verified signature. Each tenant’s space has its own webhook secret. The handler tries the secrets of the tenants registered for the webhook’s source, and the one that verifies the signature identifies the tenant. Headers and payload fields are not trusted for attribution, because they are configuration that can be wrong or forged.

Fan out shared content explicitly. Content shared between tenants, such as a group-wide legal notice, lives in a shared space. Its webhook purges the shared tag in every tenant that uses it, based on the registry’s list of subscribers.

Queue and rate-limit purges per tenant. Purges go through a queue with a per-tenant budget, so one tenant’s bulk import cannot starve the others of the CDN’s purge API.

Tag scheme for a multi-tenant frontendTag names for entry-level, type-level, navigation and shared content, with what each is attached to and what triggers its purge.TagAttached toPurged whenacme:entry:{id}responses containing the entryentry published or unpublishedacme:type:{type}listing pages of that typeany entry of that type changesacme:navevery pagenavigation entry changesshared:{id} + acme:sharedpages embedding shared contentshared entry changes, fanned out
Every tag starts with a tenant id or the shared prefix, so purges are always scoped.

Implementation

The handler below verifies the webhook against each candidate tenant’s secret, builds tenant-prefixed tags and enqueues the purge.

TypeScript
// app/api/cms-webhook/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
import { enqueuePurge } from "@/lib/purge-queue";
import { tenantsForSource, sharedSubscribers } from "@/lib/tenant-registry";

function verifies(raw: string, signature: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(raw).digest();
  const given = Buffer.from(signature, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

export async function POST(req: Request): Promise<Response> {
  const raw = await req.text();
  const signature = req.headers.get("x-webhook-signature") ?? "";
  const source = new URL(req.url).searchParams.get("source") ?? ""; // which CMS organization or instance

  // The tenant is whichever candidate's secret verifies the signature.
  const tenant = tenantsForSource(source).find((t) => verifies(raw, signature, process.env[t.webhookSecretEnv] ?? ""));
  if (!tenant) return new Response("unverified", { status: 401 });

  const event = JSON.parse(raw) as { entryId: string; contentType: string; shared?: boolean };
  const tags = tenant.id === "shared"
    ? sharedSubscribers(event.entryId).flatMap((t) => [`${t}:shared:${event.entryId}`])
    : [`${tenant.id}:entry:${event.entryId}`, `${tenant.id}:type:${event.contentType}`];

  for (const tag of tags) revalidateTag(tag);            // framework data cache
  await enqueuePurge({ tenant: tenant.id, tags });       // CDN purge, rate-limited per tenant
  return Response.json({ tenant: tenant.id, tags: tags.length }, { status: 202 });
}

The source query parameter only narrows the list of candidate tenants for efficiency; it is not trusted, because the signature decides. With one secret per tenant and a handful of tenants per source, trying each secret costs microseconds.

Tagging responses

The data layer adds the tenant prefix automatically, so developers cannot forget it, even in new code. Every fetch function takes the tenant as its first argument and builds tags from it.

TypeScript
// lib/cms/fetch.ts
export async function cmsFetch<T>(tenant: { id: string; tokenEnv: string; api: string }, path: string, tags: string[]): Promise<T> {
  const res = await fetch(`${tenant.api}${path}`, {
    headers: { Authorization: `Bearer ${process.env[tenant.tokenEnv]}` },
    next: { tags: tags.map((t) => `${tenant.id}:${t}`) }, // e.g. "entry:42" becomes "acme:entry:42"
  });
  if (!res.ok) throw new Error(`CMS ${tenant.id} ${path}: ${res.status}`);
  return (await res.json()) as T;
}

At the CDN, emit the same tags in the Surrogate-Key or Cache-Tag header of each page response, collected during rendering, so CDN purges match the data cache.

Configuration Reference

Item Recommendation Why
Tag format {tenant}:{kind}:{id} Scoped purges despite colliding ids.
Webhook secrets one per tenant space Attribution from the verified signature.
Tenant attribution secret that verifies, never headers or payload Misconfiguration cannot purge the wrong tenant.
Shared content shared space with subscriber list in the registry Explicit fan-out to subscribing tenants.
CDN purges queued, per-tenant rate limit One tenant cannot exhaust the purge quota.
Bulk operations one type-level purge instead of many entry purges Protects quotas during imports.

Gotchas & Edge Cases

  • CDN tag limits. CDNs limit the number and length of tags per response. With many referenced entries, fall back to type-level tags for listing pages rather than listing every entry.
  • Tenant-wide purges. Design changes or registry changes may require purging a tenant entirely. Tag every response with {tenant}:all so this is one purge rather than a full CDN flush.
  • Shared spaces without subscribers. If the registry’s subscriber list is out of date, shared content changes will not reach some tenants. Generate the list from usage, for example from references found during builds.
  • Replay and duplicates. Multi-tenant handlers need the same idempotency and replay protection as single-site ones; see idempotent webhook handlers.

Worked Example

The agency moved all tags to the tenant-prefixed format, gave every client space its own webhook secret, and changed the handler to attribute tenants by signature. A shared space for group-wide legal notices got a subscriber list generated from build-time references. Purges went through a queue with a per-tenant budget of 50 purges per minute, with type-level fallbacks for bulk imports. The misattribution incident class disappeared, and during a large import by one client, other clients’ purges continued without delay.

Purge latency for other tenants during a bulk importTime for a normal publish by an unrelated tenant to be purged at the CDN while another tenant ran a bulk import, before and after per-tenant purge queues.Shared purge path, before1260 seconds to purgePer-tenant queue, after4 seconds to purge
Per-tenant budgets kept one tenant's import from delaying everyone else.

Verifying Invalidation Per Tenant

Invalidation bugs in multi-tenant setups tend to be silent: a page stays stale for one tenant, or a purge clears another tenant’s cache unnecessarily, and nobody notices until a client complains. Run a synthetic publish probe per tenant group, which updates a test entry in one tenant’s space and checks that the change appears on that tenant’s site within the expected time and that a matching probe page on a second tenant’s site was not purged, which is visible in the CDN’s cache age header. Log every purge with the tenant, tags and trigger, and chart purges per tenant per hour. A tenant with zero purges during a working day, or a tenant with ten times its normal purge volume, is worth a look. These checks turn the tag scheme from a convention into something that is continuously verified.

Rollout Checklist

  • Change every tag to include the tenant prefix, via the data layer.
  • Issue one webhook secret per tenant space and attribute tenants by signature.
  • Model shared content in a shared space with a generated subscriber list.
  • Queue CDN purges with per-tenant budgets and type-level fallbacks.
  • Add a {tenant}:all tag for tenant-wide purges.
  • Probe invalidation per tenant and chart purges per tenant.

Frequently Asked Questions

Why not use one webhook secret for all tenants?

Then a verified webhook proves only that it came from one of your spaces, not which one. Per-tenant secrets make attribution cryptographic rather than configurational.

Do we still need tenant prefixes if spaces never share ids?

Yes. Ids are unique by accident, not by contract, and starter templates or content copies create collisions. Prefixes cost nothing and remove the risk entirely.

How should static builds handle tenant invalidation?

Trigger rebuilds of only the affected tenant’s site, using the same attribution. With incremental regeneration, revalidate only that tenant’s tags.

How do we purge after a registry change?

Registry changes, such as a new domain or design token set, affect every page of one tenant. Purge the tenant’s {tenant}:all tag after the registry update has propagated to every edge location, never the whole CDN, which would slow down every other tenant for no reason.

What about a CDN without tag purging?

Purge by URL, using a mapping from entries to the URLs that render them, kept per tenant. It is more work to maintain, so prefer a CDN with tags for multi-tenant setups.