Payload Adsign Plugin

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

src/collections/Articles/index.ts
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 },
    ],
};
src/payload.config.ts
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.

MechanismDefaultBasis
revalidateRelationships (incoming)ONWho embeds me — discovered by query from the schema
join-declared outgoingONWho aggregates me — declared by the schema
revalidateRelated (outgoing)OFFWhat 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 cacheTagFields value on the doc.
  • Every locale's value of a localized cacheTagFields entry — 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 join declares points back at the changed doc (automatic), plus any field you list in revalidateRelated.
  • 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-categories doc busts a menu global 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:

src/collections/ProductCategories/index.ts
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.

src/collections/MapPoints/index.ts
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:

src/globals/Header.ts
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.

src/globals/Menu.ts
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

app/api/revalidate/route.ts
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 each cacheTagFields value.
  • tags — fired as-is (plus the source prefix, like every other tag) for targets that aren't a collection or global tag at all.
  • req — when passed, honors req.context.disableRevalidate the 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.

On this page