Context
Superstack runs Next 16.2.3 / React 19.2.4, but still uses the pre-Cache-Components caching model:
next/next.config.ts does not set cacheComponents.
next/src/app/[[...uri]]/page.tsx relies on export const revalidate = 3600 plus generateStaticParams(), and reads draftMode() / cookies() at the top of the route.
next/src/lib/fetch-api.ts is a bare fetch() POST to WPGraphQL with no cache options, no tags.
next/src/app/api/revalidate/route.ts is path-based: revalidatePath(path) followed by a self-fetch() to warm the cache.
The result is one blunt hourly timer over the whole route. Every client site re-queries WordPress on a fixed schedule regardless of whether anything changed, and an editor publishing content has no precise way to update the pages affected.
Moving to Cache Components lets us cache CMS content effectively forever and invalidate precisely on publish.
Goal
Adopt cacheComponents: true and a tag-based invalidation scheme, so that:
- Public pages serve from a prerendered static shell.
- WordPress content is cached with
cacheLife('max') and invalidated by tag on publish, not on a timer.
- Preview/draft rendering continues to bypass the cache entirely.
- The pattern is documented well enough for other developers building on the starter.
Work items
1. Enable the flag
2. Split the read layer
getNodeByURI(uri, preview, auth, previewDraft, blockEnrichment, routePage, lang) cannot be cached as-is: auth carries a per-user bearer token, which would become part of the cache key (one entry per token, hit rate ≈ 0), and the two preview booleans multiply entries for a path that must never be cached.
3. Cache block data
Blocks expose getData(fetcher, attrs, lang) from their data.ts. That module is compiled into two bundles — the Next.js server and the WordPress block editor (see useGraphQlApi in FWT) — so it can never import next/cache, and the editor path must never be cached.
4. Tag scheme
| Tag |
Applied by |
Fired when |
node:{databaseId} |
cached single-node read |
that post is saved, trashed, restored |
type:{contentType} |
archive / listing / feed reads only |
any node of that type changes |
content |
listings with no content-type filter |
every save |
term:{taxonomy}:{slug} |
taxonomy archive reads |
term edited, or a post's terms change |
menu:{location} |
cached menu read |
wp_update_nav_menu |
options |
site settings / SEO defaults / FSE templates |
allowlisted options saved |
uris |
getAllURIs() |
publish, unpublish, slug change |
nodes |
every cached content read |
manual lever only — migration, deploy |
The rule that makes this work: cacheTag declares what an entry depends on; revalidateTag announces what changed. A single post page depends on one post, so it must not carry type:post — otherwise one typo fix invalidates every post of that type. Related-posts / next-previous rails are the exception, and are better cached as their own nested function carrying the type tag.
5. Rewrite the revalidate route handler
6. Verify
Known risks
- PM2 cluster mode would silently break tag revalidation. The default cache is per-instance and on-demand revalidation only invalidates the instance receiving the call.
ecosystem.config.js.example is single-process today, so we're fine — but adding instances: 'max' later would make invalidation intermittent with no error. A shared cache handler is the fix.
- Preview gets slower. Draft Mode is a master switch: when enabled, all cached functions in the tree re-execute and nothing is written to cache. On a block-heavy template that is every block's
getData on every preview load. Correct behaviour, but editors will notice. The only alternative is to stop using Draft Mode and carry preview intent explicitly, which means owning the security ourselves.
<Activity> ships with the flag. Cache Components preserves component state across client navigation, so dropdowns, dialogs and form inputs that used to reset on navigation no longer do. Not a caching change, but it will get filed as a component bug.
- Serverless vs self-hosted. With the default in-memory handler, entries may not survive between requests on serverless. No cache survives a new deploy — the build ID is part of every key.
Out of scope
use cache: private and use cache: remote.
- Custom
cacheHandlers / Redis (revisit if we ever run multiple instances).
- Migrating away from Draft Mode for preview.
References
Context
Superstack runs Next 16.2.3 / React 19.2.4, but still uses the pre-Cache-Components caching model:
next/next.config.tsdoes not setcacheComponents.next/src/app/[[...uri]]/page.tsxrelies onexport const revalidate = 3600plusgenerateStaticParams(), and readsdraftMode()/cookies()at the top of the route.next/src/lib/fetch-api.tsis a barefetch()POST to WPGraphQL with no cache options, no tags.next/src/app/api/revalidate/route.tsis path-based:revalidatePath(path)followed by a self-fetch()to warm the cache.The result is one blunt hourly timer over the whole route. Every client site re-queries WordPress on a fixed schedule regardless of whether anything changed, and an editor publishing content has no precise way to update the pages affected.
Moving to Cache Components lets us cache CMS content effectively forever and invalidate precisely on publish.
Goal
Adopt
cacheComponents: trueand a tag-based invalidation scheme, so that:cacheLife('max')and invalidated by tag on publish, not on a timer.Work items
1. Enable the flag
cacheComponents: trueinnext/next.config.ts.runtime = 'edge'(Cache Components requires the Node.js runtime).export const revalidate = 3600inapp/[[...uri]]/page.tsx— it is previous-model route config thatcacheLifesupersedes.next buildand work the resulting error/insight list.2. Split the read layer
getNodeByURI(uri, preview, auth, previewDraft, blockEnrichment, routePage, lang)cannot be cached as-is:authcarries a per-user bearer token, which would become part of the cache key (one entry per token, hit rate ≈ 0), and the two preview booleans multiply entries for a path that must never be cached.(uri, lang, routePage), no auth, no preview — and an uncached preview read that keeps the token.cacheLife('max'), pluscacheTagapplied after the fetch so it can usenode.databaseId(WordPress knows IDs; the route only knows URIs, and slugs change).getAllURIs()— no arguments, so exactly one entry per build. Cheapest win in the repo.3. Cache block data
Blocks expose
getData(fetcher, attrs, lang)from theirdata.ts. That module is compiled into two bundles — the Next.js server and the WordPress block editor (seeuseGraphQlApiin FWT) — so it can never importnext/cache, and the editor path must never be cached.getCachedBlockData(name, attributes, lang)wrapper inlib/get-block-final-component-props.tsthat callsgetDatainside the cached scope.nameis a serializable handle becauseblocksDataList[name]()is a lazy import, so every argument crossing the boundary stays plain data.cacheTagsin their result; the wrapper applies them viacacheTag. This is inert in the WordPress editor, which reads only the keys it knows.preview ? getData : getCachedBlockDataguard at the call site. It duplicates a framework guarantee today (Draft Mode already disables caching) but keeps the invariant local and stays correct if an authenticated non-draft path ever appears.cacheTags— it silently falls back to the coarse tag and is invalidated by every save.data.tsforcookies()/headers()reads. Under the wrapper these now thrownext-request-in-use-cache, and on a dynamically rendered route that surfaces undernext start, notnext build.4. Tag scheme
node:{databaseId}type:{contentType}contentterm:{taxonomy}:{slug}menu:{location}wp_update_nav_menuoptionsurisgetAllURIs()nodesThe rule that makes this work:
cacheTagdeclares what an entry depends on;revalidateTagannounces what changed. A single post page depends on one post, so it must not carrytype:post— otherwise one typo fix invalidates every post of that type. Related-posts / next-previous rails are the exception, and are better cached as their own nested function carrying the type tag.5. Rewrite the revalidate route handler
app/api/revalidate/route.tsfromrevalidatePathtorevalidateTag(tag, 'max'). The single-argument form is deprecated;'max'gives stale-while-revalidate.fetch()warm-up. It existed to spare the first visitor a blocking miss; stale-while-revalidate already does that.superhuit-agency/nextjs-revalidate(see the linked issue) — the plugin currently sends?path=&secret=.6. Verify
npm run build, thenNEXT_PRIVATE_DEBUG_CACHE=1 npm run start. Notnext dev— it adds an HMR hash to every cache key, and runtime-only errors don't surface there.x-nextjs-cachesequence around a webhook:HIT→ fire →STALE→HITwith new content. AMISSin the middle means something expired instead of going stale.app/[[...uri]]/page.tsxstill produces a static shell — itscookies()call sits inside anif (isDraftModeEnable)branch that shouldn't execute during prerender, but this is the docs' contract, not a measurement.Known risks
ecosystem.config.js.exampleis single-process today, so we're fine — but addinginstances: 'max'later would make invalidation intermittent with no error. A shared cache handler is the fix.getDataon every preview load. Correct behaviour, but editors will notice. The only alternative is to stop using Draft Mode and carry preview intent explicitly, which means owning the security ourselves.<Activity>ships with the flag. Cache Components preserves component state across client navigation, so dropdowns, dialogs and form inputs that used to reset on navigation no longer do. Not a caching change, but it will get filed as a component bug.Out of scope
use cache: privateanduse cache: remote.cacheHandlers/ Redis (revisit if we ever run multiple instances).References
use cache·cacheLife·cacheTag·revalidateTag