WABBIT · ESSAYS
Revalidate Next.js pages when Payload content is published (Payload 3)
You press Publish and the live page doesn't change. Here's the revalidation setup that fixes it in Payload 3 + Next.js, and the calls that do nothing without telling you.
- Built on
- Payload 3.90, Next.js 16 App Router
- Starts from
- Payload's website template, or any Payload 3 + Next.js app
- Steps
- 6, about 7 min read
- Files you'll touch
- 5 files, listed per step
- src/
collections/ Pages/ hooks/ revalidatePage.ts - src/
constants/ cacheTags.ts - src/
data/ posts.ts - src/
Footer/ hooks/ revalidateFooter.ts - src/
app/ api/ revalidate/ route.ts
- src/
Next.js caches aggressively. A page listed in generateStaticParams is rendered once and served from the cache, and data wrapped in unstable_cache stays cached until something says otherwise. Payload doesn't know about any of that. When an editor publishes, Payload writes to the database and the front end keeps serving the old copy.
The fix is on-demand revalidation: a Payload hook that tells Next.js which pages and which cached data just went stale. Payload's website template ships a starting version. It covers the simple case and misses a few others, like the old URL after a slug change, the listing pages that show the document, and edits made by scripts. This walkthrough builds the fuller version we run on wabbit.com (Payload 3.90, Next.js 16), simplified for a generic project.
Step 1: Know what you're caching
Before writing a hook, list what can go stale. In a typical Payload + Next.js site there are three kinds of cache:
- Rendered pages. Routes prerendered from
generateStaticParams, plus any route withexport const revalidate. You clear these withrevalidatePath. - Cached data. Anything you wrapped in
unstable_cache(or'use cache'withcacheTag, if you've turned on Cache Components). You clear these withrevalidateTag, using the tags you gave them. - Shared fragments. The header and footer, read from Payload globals, appear on every page. Clearing one page path doesn't refresh them, so they get their own tags.
Write the tag names down in one file. Ours is a handful of small builders, so a tag string is never typed by hand in two places:
Builds every tag name in one place, so the hook that clears a tag and the helper that sets it share one string.
export const collectionTag = (slug: string) =>
slug
export const docSlugTag = (
collection: string,
slug: string,
) => `${collection}:slug:${slug}`
export const globalTag = (slug: string) =>
`global:${slug}`
export const sitemapTag = (collection: string) =>
`${collection}-sitemap`The convention is the same one our platform package uses: the broad tag is the collection slug, a document tag adds the slug, a global tag is prefixed with global:. The exact strings don't matter. What matters is that the helper that caches data and the hook that clears it import the same function.
Step 2: Revalidate the page in an afterChange hook
afterChange runs after every create and update, including every autosave. The hook only acts when the saved version is published, so autosaves of a draft leave the cache alone.
Clears the page and its tags when a page is published, and the old URL when it's unpublished or moved.
import type {
CollectionAfterChangeHook,
} from 'payload'
import { revalidatePath, revalidateTag } from
'next/cache'
import type { Page } from '@/payload-types'
import { docSlugTag, sitemapTag } from
'@/constants/cacheTags'
export const pathFor = (doc: Pick<Page, 'slug'>) =>
doc.slug === 'home' ? '/' : `/${doc.slug}`
export const clearPage = (doc: Page) => {
revalidatePath(pathFor(doc))
if (doc.slug) {
revalidateTag(
docSlugTag('pages', doc.slug),
{ expire: 0 },
)
}
revalidateTag(sitemapTag('pages'), 'max')
}
export const revalidatePage:
CollectionAfterChangeHook<Page> = ({
doc,
previousDoc,
req: { payload, context },
}) => {
if (context.disableRevalidate) return doc
if (doc._status === 'published') {
payload.logger.info(`Revalidate ${pathFor(doc)}`)
clearPage(doc)
}
// Was live before: clear the old URL if the
// page was unpublished or its path changed.
if (previousDoc?._status === 'published') {
const moved =
doc._status !== 'published' ||
pathFor(doc) !== pathFor(previousDoc)
if (moved) clearPage(previousDoc)
}
return doc
}Register it on the collection:
Registers the change and delete hooks on the collection.
hooks: {
afterChange: [revalidatePage],
afterDelete: [revalidateDelete],
},The second block is the one the template's version leaves half done. The template revalidates the old path when a published page is unpublished. It doesn't when a published page's slug changes: the new URL gets rendered, and the old URL keeps serving the cached copy of a page that no longer lives there, which also keeps it in search results. Comparing the old and new paths catches both cases.
A note on revalidateTag's second argument. In Next.js 16 it's required; the one-argument form still runs but logs a deprecation warning and behaves like { expire: 0 }. 'max' marks the data stale and serves the old copy to the next visitor while fresh data loads in the background. { expire: 0 } makes the next request wait for fresh data. We use { expire: 0 } for the document the editor just published, because they're about to open it and check, and 'max' for sitemaps and lists. On Next.js 15, drop the second argument; the two-argument signature arrived in 16.
If your site has nested URLs, pathFor must match your router exactly. On wabbit.com it's the same pagePath() function that builds links, canonicals and the sitemap (covered in the Payload SEO tutorial). A revalidation path that's one segment off is accepted without complaint and clears nothing.
Step 3: Handle deletes
Deleting a document doesn't fire afterChange. It fires afterDelete, which needs the same paths and tags:
Adds the delete hook, which reuses clearPage so the two hooks cannot drift.
import type {
CollectionAfterDeleteHook,
} from 'payload'
export const revalidateDelete:
CollectionAfterDeleteHook<Page> = ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) clearPage(doc)
return doc
}It reuses clearPage from step 2, so the two hooks can't drift. Without it, a deleted page stays reachable at its old URL until the next deploy.
Step 4: Revalidate everything that lists the document
A published post doesn't only appear on its own page. It appears on the blog index, the home page's "latest" row, a category page, maybe a related-posts strip on other posts. Those are other paths, and revalidatePath on the post's own URL doesn't touch them.
There are two ways to cover them. Name the listing paths in the hook, which is what we do for the two Library index pages:
Names the listing pages a published post appears on, next to the collection tags.
if (doc._status === 'published') {
revalidatePath(`/library/essays/${doc.slug}`)
revalidatePath('/library/essays')
revalidatePath('/library')
revalidateTag(collectionTag('posts'), 'max')
revalidateTag(sitemapTag('posts'), 'max')
}Or tag the data the listings read, and revalidate the tag. Tags scale better: every page that reads posts through a helper tagged posts refreshes, including ones you'll add later and forget to put in the hook.
Caches the latest-posts query under the posts tag, so one revalidateTag call refreshes every page that reads it.
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import config from '@payload-config'
import { collectionTag } from '@/constants/cacheTags'
export const getLatestPosts = unstable_cache(
async () => {
const payload = await getPayload({ config })
const res = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 6,
})
return res.docs
},
['latest-posts'],
{ tags: [collectionTag('posts')] },
)The second argument, ['latest-posts'], is the cache key. The third, tags, is what revalidateTag matches. Mixing them up is a common reason a tag "doesn't work": a helper keyed ['posts'] with no tags option can't be revalidated by tag at all.
Globals get the same treatment. The website template reads the header and footer through a cached helper, and each global has a hook that clears its tag:
Reads a global through the cache, under a tag of its own.
export const getCachedGlobal = (
slug: 'header' | 'footer',
depth = 0,
) =>
unstable_cache(
async () => {
const payload = await getPayload({ config })
return payload.findGlobal({ slug, depth })
},
[slug],
{ tags: [globalTag(slug)] },
)Clears the footer's tag when the global is saved.
import type { GlobalAfterChangeHook } from 'payload'
import { revalidateTag } from 'next/cache'
import { globalTag } from '@/constants/cacheTags'
export const revalidateFooter:
GlobalAfterChangeHook = ({
doc,
req: { context },
}) => {
if (!context.disableRevalidate) {
revalidateTag(globalTag('footer'), 'max')
}
return doc
}Register it under hooks.afterChange in the global's config.
Step 5: Skip revalidation in scripts, then revalidate on purpose
Seeds, backfills and migrations write through Payload's Local API, and those writes fire the same hooks. Run outside a Next.js request (a tsx script, payload run, a seed), revalidatePath and revalidateTag throw Invariant: static generation store missing, because there's no Next.js cache to talk to. Every hook above checks context.disableRevalidate first, so scripts pass it:
Passes disableRevalidate so the hooks skip the Next.js calls, which throw outside a request.
await payload.update({
collection: 'posts',
id,
data: { title },
context: { disableRevalidate: true },
})That keeps the script quiet, and it also means the live site doesn't see the change. So give scripts a way to ask the running app to revalidate. Ours is a small route handler behind a shared secret, with an allow-list so it can't be used to purge the whole site:
Lets a script ask the running app to revalidate, behind a shared secret and an allow-list.
import { timingSafeEqual } from 'node:crypto'
import { revalidatePath, revalidateTag } from
'next/cache'
const ALLOWED_TAGS = new Set(['posts', 'pages'])
const okPath = (p: string) =>
p.startsWith('/library') && !p.includes('..')
const same = (a: string, b: string) => {
const x = Buffer.from(a)
const y = Buffer.from(b)
if (x.length !== y.length) return false
return timingSafeEqual(x, y)
}
export async function POST(req: Request) {
const secret = process.env.REVALIDATE_SECRET
const given = req.headers.get('x-revalidate-secret')
if (!secret || !given || !same(given, secret)) {
return Response.json({}, { status: 401 })
}
const { tags = [], paths = [] } = await req.json()
if (
!tags.every((t: string) => ALLOWED_TAGS.has(t)) ||
!paths.every(okPath)
) {
return Response.json({}, { status: 400 })
}
for (const t of tags) revalidateTag(t, 'max')
for (const p of paths) revalidatePath(p)
return Response.json({ revalidated: true })
}The script calls it once at the end, with the tags and paths it touched. Our real version validates the body shape more carefully and returns which tags or paths it rejected; the shape above is the core of it.
Inside the admin you never need this route. Payload's REST API runs as a Next.js route handler in the same app, so a hook fired by the Publish button already has the request context these functions need.
Step 6: Keep a time-based floor on listing pages
On-demand revalidation is precise, and a precise system can miss a case: a relationship that changed on another document, a page you forgot to list in a hook. For pages that aggregate many documents, we add a time-based ceiling too:
Sets a ten-minute ceiling on a listing page, as a floor under the on-demand calls.
export const revalidate = 600Ten minutes is our compromise between freshness and database load. Single-document pages don't need it, because their own hook covers them.
Common mistakes
Revalidating a route pattern without a type. revalidatePath('/posts/[slug]') with no second argument does nothing. Next.js logs a warning that it "has no effect by default" and moves on. Either pass a literal URL (/posts/my-post) or add 'page' as the second argument to clear every page matching the pattern.
Revalidating the URL instead of the route file, behind a rewrite. If a rewrite maps /blog to /news, Next.js tags the cache entry with the route that rendered it. revalidatePath('/blog') misses; revalidatePath('/news') hits. The same applies to anything served through middleware rewrites.
Unwrapped calls in hooks that scripts can reach. A hook without the disableRevalidate guard throws as soon as a seed or migration writes to that collection. Our platform package wraps revalidateTag in a helper that swallows the known out-of-request errors and logs anything else, which keeps a missed guard from failing a write. Use a wrapper like that, or keep the guard in every hook.
Scheduled publishing outside a request. With versions.drafts.schedulePublish on, publishing happens later in a Payload job, and Payload's docs are clear that scheduled events never run without a job runner. If that runner is the jobs endpoint called by a cron, the hook runs inside a request and revalidation works. If jobs run on autoRun inside the server process, there's no request around them, and the same "static generation store missing" error applies. Wrap the calls, and keep the time-based floor from step 6 on any page that can be scheduled.
A tag that was never attached. revalidateTag('posts', 'max') only clears data that was cached with that tag. A page that queries Payload directly inside a server component, with no unstable_cache and no tag, isn't in the tag's reach; only revalidatePath refreshes it. If a tag seems to do nothing, find the helper that's supposed to carry it and read its tags option.
How Tome does it
Tome's core package ships the tag builders from step 1 (collectionTag, documentSlugTag, globalTag) and a wrapper that turns the out-of-request errors from step 5 into log lines, so a script that forgets the guard logs instead of failing. Every Tome collection hook follows the shape in step 2. On wabbit.com the paths come from the same pagePath() function as previews and sitemaps, so a page revalidates at the URL it's served from. Preview, the other half of the publish flow, is in the draft preview tutorial.
FAQ
Why doesn't my Next.js page update after I publish in Payload?
The page was prerendered or its data was cached, and nothing told Next.js it changed. Add an afterChange hook to the collection that calls revalidatePath for the document's URL when doc._status === 'published', and revalidateTag for any cached data that includes it.
Should I use revalidatePath or revalidateTag with Payload?
Both. revalidatePath refreshes one rendered page. revalidateTag refreshes every cached query carrying that tag, on any page, which is what you want for lists, home-page rows and globals like the header and footer. Use the path for the document's own page and tags for everywhere else it appears.
Why does revalidatePath throw "static generation store missing" in Payload?
The hook ran outside a Next.js request, usually in a seed, migration or script using the Local API. Pass context: { disableRevalidate: true } from the script, check that flag in every hook, and revalidate afterwards through a secret-protected route handler.
Does revalidateTag need a second argument?
In Next.js 16, yes. Pass 'max' to serve stale content while fresh data loads, or { expire: 0 } to make the next request wait for fresh data. The one-argument form is deprecated. In Next.js 15, revalidateTag takes the tag only.
Do I need to revalidate when a page is unpublished or renamed?
Yes. Compare previousDoc with doc in the hook: if the previous version was published and the new one is unpublished, or its URL changed, revalidate the old path. Otherwise the old URL keeps serving the cached page.