@ggaidelevicius/payload-isr
v1.2.0
Published
Payload CMS plugin for ISR-style path revalidation with optional full rebuild fallback
Downloads
1,144
Maintainers
Readme
@ggaidelevicius/payload-isr
Payload CMS plugin that revalidates your Next.js cache when documents are published, updated, unpublished, or deleted — by path, by cache tag, or by triggering a full rebuild when route-level invalidation isn't enough.
Requirements
- Payload
^3.37.0 - Node
^18.20.2 || >=20.9.0 - Next.js is the typical target but the plugin works with any revalidation callbacks
Install
pnpm add @ggaidelevicius/payload-isr
# or
npm install @ggaidelevicius/payload-isrHow it works
The plugin registers Payload afterOperation and afterChange hooks on whichever collections and globals you configure. When a hook fires:
- Publish check —
shouldHandledecides whether the document should trigger revalidation (default: document has no_statusfield, or_status === 'published') - Unpublish check — if an update sets a document from published to draft, unpublish-specific resolvers fire instead of update resolvers
- Probe — if
probeURLis configured andfullRebuildis enabled, the plugin fetches that URL; a404triggers the full rebuild path and skips path/tag revalidation - Revalidate — resolved paths are passed to your
revalidatePathcallback; resolved tags are passed to yourrevalidateTagcallback
Delete revalidation is opt-in via onDelete on each collection target. The plugin warns at startup when a collection has update resolvers but no onDelete strategy.
Minimal setup
The smallest valid configuration — no tags, no full rebuild, one collection:
import { buildConfig } from 'payload'
import { payloadIsr } from '@ggaidelevicius/payload-isr'
import { revalidatePath } from 'next/cache'
export default buildConfig({
plugins: [
payloadIsr({
revalidatePath: (path) => revalidatePath(path),
collections: [
{
slug: 'posts',
pathResolver: ({ result }) => [`/posts/${result.slug}`, '/posts'],
},
],
}),
],
})Full example
import { buildConfig } from 'payload'
import { createPayloadIsrLogger, payloadIsr } from '@ggaidelevicius/payload-isr'
import {
revalidatePath as nextRevalidatePath,
revalidateTag as nextRevalidateTag,
} from 'next/cache'
export default buildConfig({
plugins: [
payloadIsr({
// Emit structured debug traces for every hook/guard/branch decision.
debug: true,
// Prefix relative paths in debug output with this origin so URLs are clickable.
debugURLOrigin: process.env.PUBLIC_APP_URL,
// Logger that filters noisy config-level traces by default.
logger: createPayloadIsrLogger(),
// Called once per resolved path.
// meta.mode is 'site' when a global uses revalidateAllOnChange — use
// Next.js 'layout' scope in that case to revalidate the full layout tree.
revalidatePath: (path, meta) => {
if (meta.mode === 'site') {
return nextRevalidatePath(path, 'layout')
}
return nextRevalidatePath(path)
},
// Optional. Called once per resolved tag. Omit if you don't use cache tags.
revalidateTag: (tag) => nextRevalidateTag(tag),
collections: [
{
slug: 'posts',
// Paths to revalidate when a post is published or updated.
pathResolver: ({ result }) => [`/posts/${result.slug}`, '/posts'],
// URL to probe before deciding whether a full rebuild is needed.
// If this returns 404, fullRebuild.trigger() is called instead of revalidating paths/tags.
probeURL: ({ result }) => `${process.env.PUBLIC_APP_URL}/posts/${result.slug}`,
// Cache tags to invalidate.
tagResolver: ({ result }) => ['posts', `post:${result.id}`],
// onDelete is optional, but without it this target won't revalidate on deletes.
// The plugin emits a startup warning for this misconfiguration.
onDelete: {
pathResolver: ({ id }) => [`/posts/${id}`, '/posts'],
tagResolver: ({ id }) => ['posts', `post:${id}`],
},
},
],
globals: [
{
slug: 'site-settings',
// Revalidate the entire site (calls revalidatePath('/', 'layout') via meta.mode === 'site').
// Cannot be combined with pathResolver on the same target.
revalidateAllOnChange: true,
tagResolver: () => ['site-settings', 'global'],
},
],
fullRebuild: {
// Disable in development to avoid accidentally triggering deploys.
enabled: process.env.NODE_ENV === 'production',
// Fires when a probeURL returns 404 (the default shouldTrigger condition).
trigger: async () => {
await fetch(process.env.REBUILD_WEBHOOK_URL!, { method: 'POST' })
},
},
}),
],
})Plugin options
Top-level (payloadIsr({ ... }))
| Option | Type | Required | Description |
|---|---|---|---|
| revalidatePath | (path, meta) => void \| Promise<void> | No* | Called for each resolved path. See Revalidation metadata. |
| revalidateTag | (tag, meta?) => void \| Promise<void> | No* | Called for each resolved tag. Omit only if you do not use tag-based caching. |
| collections | CollectionISRTarget[] | No | Collection revalidation targets. |
| globals | GlobalISRTarget[] | No | Global revalidation targets. |
| fullRebuild | FullRebuildConfig | No | Full rebuild fallback. See Full rebuild fallback. |
| logger | { error, warn, info? } | No | Custom logger. Defaults to console. Use createPayloadIsrLogger() for structured output with built-in filtering. |
| debug | boolean | No | Emit structured trace events via logger.info for every hook, guard, and branch decision. |
| debugURLOrigin | string | No | Base URL prepended to relative paths in debug output, making logged paths absolute and clickable. |
| disabled | boolean | No | Disable the plugin entirely without removing it from config. |
* At least one of revalidatePath or revalidateTag is required.
TypeScript note: callback args in collections and globals are inferred from each target's slug using Payload's generated types. Invalid field access is caught at compile time. Each target requires at least one revalidation strategy at the type level — the compiler will error if a target has no resolver.
Collection target
| Option | Type | Default | Description |
|---|---|---|---|
| slug | string | — | Collection slug. Must match your Payload collection. |
| disabled | boolean | false | Skip this target without removing it from config. |
| pathResolver | (args) => string[] | — | Returns paths to revalidate when a document is published or updated. |
| tagResolver | (args) => string[] | — | Returns cache tags to invalidate. |
| referencePathResolver | (args) => string[] | — | Additional paths to revalidate, merged with pathResolver results. Use findReferencingPaths() here to also bust other pages that embed this document. |
| referenceTagResolver | (args) => string[] | — | Additional tags to invalidate, merged with tagResolver results. |
| probeURL | (args) => string | — | URL to fetch before triggering full rebuild. Required for full rebuild to ever fire on this target. |
| operations | string[] | ['create', 'update', 'updateByID'] | Which Payload operations trigger this target. |
| shouldHandle | (args) => boolean | Checks _status === 'published' (or no _status field) | Custom gate. Return false to skip revalidation for this operation. |
| unpublish | UnpublishConfig | — | Override unpublish detection and/or unpublish-specific path/tag resolvers. |
| onDelete | OnDeleteConfig | — | Opt-in delete revalidation. Without this, deletes are skipped for this target and a startup warning is emitted. |
At least one of pathResolver, tagResolver, referencePathResolver, referenceTagResolver, or probeURL is required at the type level.
Global target
| Option | Type | Default | Description |
|---|---|---|---|
| slug | string | — | Global slug. Must match your Payload global. |
| disabled | boolean | false | Skip this target without removing it from config. |
| revalidateAllOnChange | boolean | — | Revalidate the entire site on every change. Passes meta.mode === 'site' to revalidatePath. Cannot be combined with pathResolver. |
| revalidateAllPath | string | '/' | Path passed to revalidatePath when revalidateAllOnChange is enabled. Must be absolute. |
| pathResolver | (args) => string[] | — | Returns targeted paths. Use this when revalidateAllOnChange is too broad. |
| tagResolver | (args) => string[] | — | Returns cache tags to invalidate. |
| shouldHandle | (args) => boolean | Checks _status === 'published' (or no _status field) | Custom gate. |
| probeURL | (args) => string | — | URL to probe before full rebuild decision. |
At least one strategy required: revalidateAllOnChange: true, or at least one of pathResolver, tagResolver, probeURL.
Full rebuild config
| Option | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | — | Whether full rebuild is active. Recommended: process.env.NODE_ENV === 'production'. |
| trigger | (context) => void \| Promise<void> | — | Required. What to call when a rebuild is triggered. |
| shouldTrigger | (context) => boolean | context.probeStatus === 404 | Override the condition for triggering. |
trigger receives a FullRebuildContext with: probeStatus, probeURL, reason, scope, slug.
Revalidation metadata
When configured, both revalidatePath and revalidateTag receive a meta argument describing why revalidation is happening.
revalidatePath(path, meta)
meta: {
mode: 'path' | 'site' // 'site' only for globals with revalidateAllOnChange
reason: RevalidationReason
scope: 'collection' | 'global'
slug: string
}The mode field is the primary reason to branch on meta — Next.js requires a 'layout' scope argument when you want to invalidate the full layout tree:
revalidatePath: (path, meta) => {
if (meta.mode === 'site') {
return nextRevalidatePath(path, 'layout')
}
return nextRevalidatePath(path)
},revalidateTag(tag, meta?)
meta?: {
reason: RevalidationReason
scope: 'collection' | 'global'
slug: string
}Revalidation reasons
| Reason | When |
|---|---|
| 'collection-update' | Collection create or update operation |
| 'collection-unpublish' | Update that transitions a document from published to draft |
| 'collection-delete' | Collection delete operation |
| 'global-update' | Global doc change |
Default behaviors
These are the defaults to be aware of — getting them wrong is a common source of missed revalidation:
shouldHandle — defaults to doc._status === 'published' or, if the document has no _status field at all, true. If you don't use Payload drafts, every publish/update will pass the default guard automatically. If you do use drafts, only published docs trigger revalidation by default. Override with a custom shouldHandle to change this.
operations — defaults to ['create', 'update', 'updateByID']. Custom Payload operations are not included. Override if you need to respond to additional operation types.
onDelete — not configured by default. Deletes are skipped without it. The plugin warns at startup for collection targets that define update resolvers but no onDelete.
revalidateAllPath — defaults to '/' when revalidateAllOnChange: true.
fullRebuild.shouldTrigger — defaults to context.probeStatus === 404. Override to trigger on other status codes or custom logic.
fullRebuild without probeURL — if fullRebuild is enabled but no target has a probeURL, no probe ever runs and the rebuild trigger is never reached. The plugin emits a warning at startup.
Invalid probeURL values — if a resolver returns an empty, relative, or non-HTTP URL, the plugin warns and skips probing for that operation.
Unpublish detection
When a collection update is detected as an unpublish (a document transitioning from published to draft), the plugin uses unpublish-specific resolvers if provided, falling back to the main resolvers.
Default unpublish matcher: the operation must be updateByID and request data must include _status: 'draft'. Extra fields are allowed.
If your app uses a different field to control publish state (e.g. isPublished: boolean), provide a custom unpublish.matcher:
{
slug: 'posts',
pathResolver: ({ result }) => [`/posts/${result.slug}`, '/posts'],
unpublish: {
// Detect your custom unpublish pattern
matcher: ({ args, operation }) => {
const data = args.req.data as Record<string, unknown>
return operation === 'updateByID' && data.isPublished === false && Object.keys(data).length === 1
},
// Optional: different paths/tags on unpublish (falls back to main resolvers if omitted)
pathResolver: ({ result }) => [`/posts/${result.slug}`, '/posts'],
tagResolver: ({ result }) => ['posts', `post:${result.id}`],
},
}Delete revalidation
Deletes do not trigger revalidation unless onDelete is configured on the target. The plugin warns at startup when onDelete is missing. The delete hook receives the deleted document and its ID:
{
slug: 'posts',
pathResolver: ({ result }) => [`/posts/${result.slug}`, '/posts'],
onDelete: {
pathResolver: ({ doc, id }) => [
// doc is the deleted document — use its fields if available
typeof doc.slug === 'string' ? `/posts/${doc.slug}` : `/posts/${id}`,
'/posts',
],
tagResolver: ({ id }) => ['posts', `post:${id}`],
},
}Finding referencing pages
When a document changes, other pages that embed or reference that document may also need their cache busted — for example, a pages entry that includes a post inside its block layout.
findReferencingPaths() handles this automatically. You give it the changed document's ID and the collections/globals to search, and it returns the paths of any documents that contain a reference to it. Use it inside referencePathResolver:
import { findReferencingPaths, payloadIsr } from '@ggaidelevicius/payload-isr'
{
slug: 'posts',
pathResolver: ({ result }) => [`/posts/${result.slug}`, '/posts'],
referencePathResolver: ({ req, result }) =>
findReferencingPaths({
payload: req.payload,
referencedValues: result.id,
targets: {
collections: ['pages', 'news'],
globals: ['homepage'],
},
fieldPaths: ['layout'],
}),
}This fetches candidate documents from pages, news, and homepage, then applies the default published filter before matching references and returning paths.
How matching works
The helper recursively walks the values at each fieldPath (or the roots returned by getSearchRoots) and checks whether any string or number matches one of the referencedValues. You don't need to account for nesting — the search is depth-unlimited within the extracted roots.
By default, only published documents are candidates (_status === 'published', or docs without _status). Override this with shouldInclude.
By default, queries run with depth: 0 and overrideAccess: true. This keeps relationship values as IDs (better for stable matching) and avoids access-scoped misses in system-level revalidation. Override with queryDepth / overrideAccess if your project needs different behavior.
How paths are resolved
For each matching document, the path is determined by the first of these that applies:
- The last URL in its
breadcrumbsarray (if present and starts with/) /${doc.slug}(if the document has a non-emptyslugfield)/${doc.id}/— only for globals that have none of the above
Override with resolvePaths when your routing doesn't follow this convention. Paths returned by resolvePaths must be absolute (start with /).
Telling the helper where to look
The helper doesn't know which fields store your block or relation data — you need to point it there.
fieldPaths accepts dot-separated paths to the fields that contain block or relation data:
fieldPaths: ['layout', 'hero.blocks', 'sidebar.content']getSearchRoots is an escape hatch for when references don't live in predictable fields — it receives the full document and returns the values to search:
getSearchRoots: (doc, meta) => {
// Search the entire document for pages, only the hero section for others
if (meta.slug === 'pages') return [doc]
return [doc.hero]
}When both fieldPaths and getSearchRoots are provided, their results are combined. At least one is required.
Custom path resolution
Override resolvePaths when the default path convention doesn't match your routing:
referencePathResolver: ({ req, result }) =>
findReferencingPaths({
payload: req.payload,
referencedValues: result.id,
targets: {
collections: ['pages', 'news'],
},
fieldPaths: ['hero.blocks', 'layout'],
resolvePaths: (doc, meta) => {
if (meta.slug === 'news' && typeof doc.slug === 'string') {
return [`/news/${doc.slug}`]
}
return typeof doc.slug === 'string' ? [`/${doc.slug}`] : []
},
})Options reference
| Option | Required | Description |
|---|---|---|
| payload | Yes | Payload instance. Available as args.req.payload in hook resolvers. |
| referencedValues | Yes | The ID(s) to search for — typically result.id. Accepts a single value or an array; null/undefined entries are ignored. |
| targets | Yes | { collections?, globals? } — which slugs to scan for references. |
| fieldPaths | One of fieldPaths/getSearchRoots | Dot-separated field paths to inspect on each candidate document (e.g. ['layout', 'hero.blocks']). Fields that don't exist on a document are silently skipped. |
| getSearchRoots | One of fieldPaths/getSearchRoots | Function returning the values to search for a given document. Combined with fieldPaths results when both are provided. |
| resolvePaths | No | Custom route mapper for matching documents. Must return absolute paths (starting with /). Defaults to: breadcrumb URL → /${slug} → /${id} → / (globals only). |
| shouldInclude | No | Custom filter applied to each candidate document before the reference search runs. Defaults to published docs or docs without _status. |
| queryDepth | No | Depth passed to Payload queries. Defaults to 0. Increase if you intentionally want populated relationship objects in the search roots. |
| overrideAccess | No | Whether to bypass access control when scanning candidates. Defaults to true to avoid missed revalidation due to user-scoped access. |
| logger | No | Logger for warnings from the helper (failed queries, non-absolute paths). Defaults to console. Pass the same logger you configured on the plugin to keep output consistent. |
Database adapter notes
MongoDB — MongoDB stores relationship IDs embedded within each document's structure. There is no join table or foreign-key index to reverse-query, so finding "all pages that reference post X" requires a full collection scan regardless. findReferencingPaths is the practical solution here.
PostgreSQL / SQLite — Payload's SQL adapters store relationships in dedicated join tables (e.g. pages_rels). In principle you could query those tables directly to find referencing documents without fetching full documents. findReferencingPaths doesn't do this — it uses payload.find() and scans the returned documents in memory, which is database-agnostic but less targeted than a raw SQL query. For large collections on a SQL adapter, a direct Drizzle query against the _rels table would be more efficient, though it requires knowing Payload's internal table naming conventions (which vary by collection and field structure).
Performance note
findReferencingPaths fetches every document in each target collection on every publish event. Keep targets narrow — only include collections that actually store references to the changed document type.
Full rebuild fallback
fullRebuild is a fallback for situations where path/tag invalidation misses newly valid routes — for example, when a slug changes and the new URL has never been cached, or when catch-all routes return 404 until a new build is deployed.
When to enable it:
- Slug or route shape changes that create a URL not yet present in the current deployment
- Catch-all or nested routing where newly published content returns
404until a rebuild - CDN or edge cache scenarios where route/tag revalidation alone is insufficient
How it works:
fullRebuild.enabledmust betrue- The target must have a
probeURLresolver — without it, no probe runs - Plugin fetches
probeURLafter a publish/update - If
fullRebuild.shouldTrigger(context)returnstrue(default:probeStatus === 404),trigger()is called - Path/tag revalidation is skipped when a rebuild triggers
fullRebuild: {
enabled: process.env.NODE_ENV === 'production',
trigger: async (context) => {
// context: { probeStatus, probeURL, reason, scope, slug }
await fetch(process.env.REBUILD_WEBHOOK_URL!, { method: 'POST' })
},
}Common trigger implementations:
- Generic HTTP deploy webhook
- CI/CD provider API (GitHub Actions, GitLab CI, CircleCI)
- Hosted platform rebuild endpoint (Vercel, Netlify, Cloudflare)
- Internal queue or job dispatcher
Route contract alignment
All ISR inputs for a content type should map to the same routing contract your app actually serves. Misalignment means revalidating paths that aren't cached, or probing URLs that don't match what's live.
Rules of thumb:
pathResolvershould return the paths your app actually caches and servesprobeURLshould be the user-facing URL you expect to exist after a successful deploy- If your app serves both
/posts/[id]and/posts/[slug], revalidate both - If a slug can change,
referencePathResolvercan revalidate the previous URL if your resolver can derive it (e.g. from stored history)
Example — helper functions that stay consistent across resolvers:
const getPostPaths = (doc: { id: string | number; slug?: null | string }) => {
const idPath = `/posts/${String(doc.id)}`
const slugPath = typeof doc.slug === 'string' && doc.slug.trim() ? `/posts/${doc.slug.trim()}` : null
return [slugPath, idPath, '/posts'].filter(Boolean) as string[]
}
const getPostProbeURL = (doc: { id: string | number; slug?: null | string }) =>
new URL(
typeof doc.slug === 'string' && doc.slug.trim()
? `/posts/${doc.slug.trim()}`
: `/posts/${String(doc.id)}`,
process.env.PUBLIC_APP_URL,
).toString()
// Use in config:
{
slug: 'posts',
pathResolver: ({ result }) => getPostPaths(result),
probeURL: ({ result }) => getPostProbeURL(result),
onDelete: {
pathResolver: ({ doc, id }) => getPostPaths({ id, slug: doc.slug }),
},
}Debug and logging
Enable debug traces:
payloadIsr({
debug: true,
logger: createPayloadIsrLogger(),
// ...
})createPayloadIsrLogger() prefixes output with [payload-isr] and filters out noisy config.* setup traces by default. To include config-level events:
logger: createPayloadIsrLogger({ includeConfigDebugEvents: true })Bring your own logger by passing any object with error, warn, and optionally info methods:
logger: {
error: (...args) => myLogger.error(...args),
warn: (...args) => myLogger.warn(...args),
info: (...args) => myLogger.info(...args),
}Startup preflight warnings — the plugin validates configuration at init and warns about:
- Duplicate target slugs (hooks may fire multiple times)
- Targets with no revalidation strategy
- Collection targets missing delete strategy (
onDelete) - Collection targets with
onDeleteconfigured but no resolvers inside it - Path resolvers configured without a
revalidatePathcallback tagResolverwithout arevalidateTagcallbackfullRebuildenabled with noprobeURLresolversrevalidateAllOnChangeandpathResolveron the same global target- Non-absolute
revalidateAllPath
Local development
pnpm hooks:install # install commit-msg and other git hooks
pnpm dev # start the bundled dev app
pnpm test:int # run integration tests
pnpm build # build the pluginEnvironment variables for the bundled dev/ app:
| Variable | Default | Description |
|---|---|---|
| PAYLOAD_ISR_DEBUG | 1 | Emit branch-level debug traces |
| PAYLOAD_ISR_DEBUG_CONFIG | off | Include noisy config.* trace events |
| PAYLOAD_ISR_FULL_REBUILD | off | Enable full-rebuild fallback simulation |
| PAYLOAD_ISR_PROBE_ORIGIN | http://127.0.0.1:3000 | Base URL for probe requests |
Dev API endpoints:
GET /api/isr-debug— inspect recorded revalidation telemetryDELETE /api/isr-debug— clear recorded telemetry
Release commit marker guard: commit messages containing (release...) must use exactly one of (release:patch), (release:minor), or (release:major). Invalid values (e.g. (release:path)) are rejected by the commit-msg hook.
