Revalidation
Opt collections and globals into plugin-managed revalidation via `custom.revalidate`
In autoMode (the default), anything with custom.revalidate set gets a
revalidation hook attached at plugin init. true enables the defaults;
object form lets you override.
Setup
import type { CollectionConfig } from 'payload';
export const Articles: CollectionConfig = {
slug: 'articles',
custom: {
revalidate: {
cacheTagFields: ['slug'],
},
},
fields: [
{ name: 'slug', type: 'text' },
{ name: 'myProjects', type: 'relationship', relationTo: 'projects', hasMany: true },
],
};adsignPlugin({
revalidation: {
url: process.env.REVALIDATE_CACHE_URL,
secret: process.env.REVALIDATE_CACHE_SECRET_TOKEN,
source: 'adsign-cms',
},
});Short form — just opt in with defaults:
custom: { revalidate: true }What gets invalidated
When a doc changes, the plugin fires tags for:
- The collection itself and each
cacheTagFieldsvalue on the doc. - Every doc reachable through a relationship/upload field on the changed doc.
- Every doc in other collections that references this one via a relationship/upload field.
- Every global whose schema references the changed collection via a
relationship/upload field — its slug tag is fired so embedded data stays fresh
(e.g. renaming a
product-categoriesdoc busts amenuglobal that lists them).
All of that is automatic — no extra config.
The collection→global fan-out is schema-based: a global's tag is fired whenever it could embed the changed collection, without reading the global's data. It may occasionally invalidate a global that doesn't actually reference the specific doc — a single coarse tag, intentionally cheap.
Scoping the fan-out — revalidateRelationships
The incoming fan-out (the last two bullets above) is on by default. For a
collection referenced by many documents, firing one revalidation per
referencing doc on every save can be slow. Use revalidateRelationships to scope
or disable it. It's a denylist — everything is included unless you set a
target to false:
custom: {
revalidate: {
cacheTagFields: ['slug'],
revalidateRelationships: {
collections: { products: false }, // skip the costly per-product fan-out
// globals: { menu: false }, // (menu stays on — it embeds categories)
},
},
}revalidateRelationships: true(or omitted) — fan out to every referencing collection and global.revalidateRelationships: false— skip the incoming fan-out entirely (the collection still fires its own tags +cacheTagFields).- Object form — fan out to everything except targets set to
false.
Only false is accepted per target. Setting a target to true is a type error
— the previous allowlist form used true, so old usage surfaces at compile time.
Scoping the fan-out — revalidateRelated
There are two independent fan-out directions on every save:
- Incoming (
revalidateRelationships, above) — docs that point at the changed doc. - Outgoing (
revalidateRelated, here) — the changed doc's own relationship/upload fields; the plugin fires the cache tags of the docs they point to.
Outgoing is on by default for every own relationship field, so its cost
scales with list size on hasMany fields. Concrete foot-gun: a campaigns
collection has a large products relationship. Every campaign save fires one
revalidation POST per product — even though product pages render no campaign
data. Pure churn. revalidateRelated is the opt-out, and like the incoming side
it's a denylist:
custom: {
revalidate: {
cacheTagFields: ['slug'],
revalidateRelated: { products: false }, // stop the per-product churn
// every other relationship field is still followed outgoing
},
}revalidateRelatedomitted — follow every own relationship/upload field.- Object form — follow every own field except those set to
false. revalidateRelated: false— disable the outgoing fan-out entirely (the collection still fires its own tags,cacheTagFields, and the incoming fan-out).
Same denylist rule as revalidateRelationships: only false is accepted per
field — { products: true } is a type error. A denied name that isn't an actual
relationship/upload field logs a [Revalidate] No relationship field named …
warning at init so typos surface.
Globals
Same opt-in as collections. Globals are singletons so the short form is enough:
export const Header: GlobalConfig = {
slug: 'header',
custom: { revalidate: true },
fields: [/* ... */],
};A change fires the header tag (plus ${source}_header when source is set).
Globals also follow their own relationship/upload fields outgoing, so the same
revalidateRelated denylist applies — use the object form to scope it:
export const Menu: GlobalConfig = {
slug: 'menu',
custom: {
revalidate: { revalidateRelated: { featuredProducts: false } },
},
fields: [/* ... */],
};Wire format
One POST per tag. Body is always { secret, tag }:
POST <revalidation.url>
Content-Type: application/json
{ "secret": "…", "tag": "articles_hello-world" }When source is configured, it's prefixed into every tag at the publisher:
{ "secret": "…", "tag": "adsign-cms_articles_hello-world" }Opting out — legacyMode
To keep the old body shape and wire revalidateHook manually, set
mode: 'legacyMode':
adsignPlugin({
revalidation: {
url: process.env.REVALIDATE_CACHE_URL,
secret: process.env.REVALIDATE_CACHE_SECRET_TOKEN,
mode: 'legacyMode',
},
});In legacyMode, source is forbidden and any collection/global with
custom.revalidate set will cause the plugin to throw at init.
Next.js receiver
import type { NextRequest } from 'next/server';
import { revalidateFromPayload } from '@adsign/payload-adsign-plugin/next';
export async function POST(request: NextRequest): Promise<Response> {
return revalidateFromPayload(request, {
secret: process.env.REVALIDATE_CACHE_SECRET_TOKEN!,
});
}revalidateFromPayload parses the body, validates body.secret, and calls
revalidateTag(body.tag, 'max'). Pass { secret, profile } to override the
revalidate profile.