Caching Payload + Next.js Without Cache Components
Opt-in caching with tags beats both force-dynamic-everything and the all-in Cache Components flag—which broke our admin. Here’s the architecture that keeps the CMS off the request path without touching /admin.

We ran every page force-dynamic for months. Every visitor triggered a MongoDB round-trip through Payload CMS, even for unchanging blog posts. We considered flipping on Next.js Cache Components—until the flag broke our /admin routes entirely. There is a middle lane, and it solves payload cms next.js caching without sacrificing the admin panel.
For a marketing site powered by Payload, pure static generation doesn’t work because editors expect instant previews. Pure dynamic rendering works but burns database connections on every hit. The solution: wrap Payload data loaders in unstable_cache, tag them, bust them via Payload hooks, and leave the admin untouched. No Cache Components flag required.
payload cms next.js caching: The Architecture That Keeps Admin Alive
Takeaway: You don’t need the Cache Components flag to get HTML served from the edge. Wrap Payload queries in unstable_cache, leave the admin routes alone, and you’ll keep MongoDB off the hot path.
Our stack is Next.js App Router with Payload mounted on a route group. Enabling the useCache/Cache Components mode meant the compiler started hoisting and bundling client‑server boundaries in ways that tripped up Payload’s admin bundle. Build failures, missing globals, and an admin panel that refused to hydrate. The fix wasn’t debugging the flag—it was never setting it.
The insight: Next.js’s previous caching model (fetch options, unstable_cache, route segment configs) is still the primary model for apps not using Cache Components. For us, that meant we could cache individual data-fetching functions while the admin remained a fully dynamic catch-all.
Opt-in Caching With unstable_cache and Tags
Takeaway: Don’t cache the route—cache the Payload query. Tag each query with a unique key so you can surgically bust it when content changes.
We create a loader for each content type and wrap the Payload find call:
// lib/payload-loaders.ts
import { unstable_cache } from 'next/cache'
import { getPayload } from '@/payload/config'
async function getPostsUncached() {
const payload = await getPayload()
return payload.find({ collection: 'posts', depth: 2, limit: 10 })
}
export const getPosts = unstable_cache(getPostsUncached, ['posts-list'], {
tags: ['posts'],
})The ['posts-list'] key-parts distinguish this cache entry from other queries against the same collection—say, a single-post lookup or a sidebar widget. The tags array is the invalidation mechanism. One tag can span multiple cached functions, which means a single revalidateTag('posts') call will bust the blog index, the RSS feed loader, and any sidebars that pull posts.
This tagging approach comes straight from Payload projects we’ve moved off force-dynamic. We typically wrap every read-side loader this pattern during the CMS migration phase, because it’s isolated to data access and doesn’t force a rethink of the routing layer.
Automated Invalidation With Payload Hooks
Takeaway: Tie revalidateTag to Payload’s afterChange hook. No manual button, no cron job—editors publish, the cache clears automatically.
In Payload’s collection config:
// collections/Posts.ts
import { revalidateTag } from 'next/cache'
const Posts = {
slug: 'posts',
hooks: {
afterChange: [
async () => {
revalidateTag('posts')
},
],
},
}We use one hook per collection. For globals, we call revalidateTag inside afterChange on the global config itself. This keeps the mapping clear: the posts tag covers anything derived from the posts collection. If a post change also updates a homepage hero that pulls the latest three posts, that homepage query carries the posts tag too.
This is the pattern we set up for content teams during platform engagements—it removes the need for editors to understand cache layers at all.
The Revalidate Window That Keeps Layouts Fresh
Takeaway: A small revalidate on the shared layout ensures repeat visitors get fresh shell-chrome without triggering per-request Payload calls for every mouse click.
Our root layout declares:
// app/layout.tsx
export const revalidate = 60This doesn’t override unstable_cache settings on individual loaders—it applies to the full route cache. With Cache Components off, the route segment config still works. Repeat visitors within that 60-second window hit ISR-served HTML. After the window expires, the next request triggers a background regeneration that re-executes the cached loaders. If a tag was already busted by an afterChange hook, that regeneration pulls fresh data.
The key detail: 60 seconds is short enough that marketing edits appear quickly, but long enough that a traffic spike from Hacker News doesn’t slam the database. We tuned this after seeing Vercel function durations spike during production traffic tests.
Draft Preview: Bypassing Cache Entirely
Takeaway: Payload’s draft preview mechanism must ignore every cache layer. We handle this by checking the draft search param and calling uncached loaders directly.
// app/(pages)/posts/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { getPost, getPostUncached } from '@/lib/payload-loaders'
export default async function PostPage({ params, searchParams }) {
const { isEnabled } = draftMode()
const isPreview = searchParams?.draft === 'true'
const post = isEnabled || isPreview
? await getPostUncached(params.slug)
: await getPost(params.slug)
// ...
}We keep uncached versions of every loader exported and use Payload’s own preview mechanism, which sets draftMode() and passes the right headers. Editors navigating from the admin panel into the frontend will always see real-time state. The cache is completely invisible to them.
What Happens Under Load
Takeaway: In production on Vercel, cached pages return HTML immediately. MongoDB and the Payload server never leave the starting blocks for those requests.
With force-dynamic, every page hit in Vercel’s function logs showed a MongoDB query and a serialization step through Payload. After moving to tagged unstable_cache:
- Anonymous visitors serve directly from Vercel’s edge cache (within the
revalidatewindow). - Even cold-start function invocations only hit the in-memory data cache if tags are fresh, avoiding the database round-trip.
- Admin panel requests remain completely dynamic—Payload’s REST and GraphQL handlers aren’t wrapped in any cache function, so they operate as normal.
We’ve applied this exact pattern for teams that come to us at the start of a new build because it’s easier to layer caching into loaders from day one than to retrofit it after months of force-dynamic.
FAQ
Does unstable_cache work in Next.js 16 without Cache Components?
Yes. The Cache Components mode is opt-in. unstable_cache and fetch-level caching remain the default path in Next.js 16 as long as you don’t enable the flag. We run this setup in production on 16.2+ without issues.
Will this pattern break Payload’s admin panel?
No, because you never wrap admin routes in cache functions. Payload’s admin lives under /admin and makes direct API calls to its own REST/GraphQL endpoints. Don’t add unstable_cache wrappers to those handlers, and they’ll stay fully dynamic.
How many tags should I create?
One tag per logical content aggregate. For a blog, that’s usually posts and pages. A job board might add jobs and companies. The goal is surgical invalidation: changing a blog post shouldn’t bust the entire site cache.