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',
batch: true,
},
});Short form — just opt in with defaults:
custom: { revalidate: true }What is automatic and what you opt into
Automatic when the schema tells us; explicit when we would be guessing.
| Mechanism | Default | Basis |
|---|---|---|
revalidateRelationships (incoming) | ON | Who embeds me — discovered by query from the schema |
| join-declared outgoing | ON | Who aggregates me — declared by the schema |
revalidateRelated (outgoing) | OFF | What I point at — a guess, so you opt in |
A join field states that a parent renders a list of children, so the plugin
follows the join's on field automatically. Any other outgoing relationship is
a guess about your rendering — list it in revalidateRelated or it is not
followed.
What gets invalidated
When a doc changes, the plugin fires tags for:
- The collection itself and each
cacheTagFieldsvalue on the doc. - Every locale's value of a localized
cacheTagFieldsentry — a Norwegian slug and an English slug both fire, whichever locale the editor saved in. - The previous value of a tag field that just changed, so the URL a rename abandoned stops serving stale content. Deleting a doc fires every locale's tag.
- Every doc reachable through a field a
joindeclares points back at the changed doc (automatic), plus any field you list inrevalidateRelated. - 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).
Tags never contain a locale. A localized field emits one tag per locale value
(articles_min-artikkel, articles_my-article), so website fetch helpers keep
passing the current locale's value as tagSuffix and need no change.
All of that is automatic — no extra config.
A bulk update or delete — the admin's select-all, or payload.update() /
payload.delete() with a where — fires only the collection tag, never
per-document tags. This is deliberate: one bulk write would otherwise publish a
tag per document per locale from inside the still-open transaction. Plan
revalidation separately for a large bulk edit.
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.
Outgoing fan-out — revalidateRelated
Outgoing means the changed doc's own relationship/upload fields: the plugin
fires the cache tags of the docs they point to. Nothing is followed unless a
join declares it or you list it.
import type { MapPoint } from '@/payload-types';
import { revalidate } from '@adsign/payload-adsign-plugin';
custom: revalidate<MapPoint>({
cacheTagFields: ['id'],
revalidateRelated: ['concept'],
}),The generic narrows cacheTagFields and revalidateRelated to the collection's
own field names, so a typo fails to compile instead of producing a tag that never
fires. Without it both accept any string[].
- omitted — nothing followed beyond join-declared fields.
['fieldName']— follow those fields, in addition to join-declared ones.
You rarely need this. A join already declares the parent that aggregates a
doc, and those are followed automatically. Reach for revalidateRelated when a
page aggregates children through something other than a join.
A localized relationship field listed here is followed using the saved
locale's value only, so a doc it points at in another locale is not invalidated.
List that doc's collection in a custom hook with
revalidateTags() if it matters.
Turning off join-derived hops — revalidateJoins
custom: { revalidate: { cacheTagFields: ['slug'], revalidateJoins: false } }Use it when a join exists purely as an admin affordance — orderable: true
drag-ordering whose parent the website never fetches with its join. Explicit
revalidateRelated entries still apply.
A join with a nested on path (on: 'products.product') is skipped with an
init warning — the pointer sits inside an array on the other collection, which
is not a simple field read. List the field in revalidateRelated or handle it
in a custom hook with revalidateTags().
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 follow the same rules as collections: join-declared fields are automatic, everything else needs an allowlist entry.
export const Menu: GlobalConfig = {
slug: 'menu',
custom: {
revalidate: { revalidateRelated: ['featuredProducts'] },
},
fields: [/* ... */],
};Wire format — batch
batch is required. It picks the request shape for every publish:
batch: true — one POST per URL, carrying every tag from the save:
POST <revalidation.url>
Content-Type: application/json
{ "secret": "…", "tags": ["articles_hello-world", "articles"] }batch: false — one POST per tag, deprecated:
POST <revalidation.url>
Content-Type: application/json
{ "secret": "…", "tag": "articles_hello-world" }Setting batch: false logs a deprecation warning at init. When source is
configured, it's prefixed into every tag at the publisher either way:
{ "secret": "…", "tags": ["adsign-cms_articles_hello-world"] }Migrate in this order: upgrade every receiver to accept revalidateFromPayload
(it already handles both shapes), then flip batch: true on the CMS. Flipping
batch before the receiver is upgraded sends { secret, tags } to a receiver
that only reads body.tag, and nothing revalidates.
Opting out — legacyMode
To keep wiring revalidateHook manually, set mode: 'legacyMode'. batch
still applies to the wire format:
adsignPlugin({
revalidation: {
url: process.env.REVALIDATE_CACHE_URL,
secret: process.env.REVALIDATE_CACHE_SECRET_TOKEN,
mode: 'legacyMode',
batch: true,
},
});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 accepts both wire shapes. A { secret, tags } body
revalidates each tag and responds with { revalidated: true, tags, now }; a
{ secret, tag } body revalidates that one tag and responds with
{ revalidated: true, tag, now }. Each tag is passed to
revalidateTag(tag, profile), profile defaulting to { expire: 0 }. Pass
{ secret, profile } to override it.
Custom fan-out
Reach for revalidateTags when a hook needs to fire tags the schema-driven
fan-out has no way to know about — a webhook from an external system, a
computed relationship, or a tag with no collection or global behind it at all.
import { revalidateTags } from '@adsign/payload-adsign-plugin';
await revalidateTags({
collections: ['articles'],
globals: ['menu'],
documents: [{ collection: 'products', cacheTagFields: { slug: 'widget' } }],
tags: ['homepage'],
req,
});collections/globals— fires the slug tag directly.documents— fires${collection}_${value}for eachcacheTagFieldsvalue.tags— fired as-is (plus thesourceprefix, like every other tag) for targets that aren't a collection or global tag at all.req— when passed, honorsreq.context.disableRevalidatethe same way the built-in hooks do, so it's a no-op inside a request that has revalidation turned off.
It inherits url, secret, source, batch, the fetch timeout, and the same
logging as every other publish — there's one publisher underneath all of this.