Skip to content

Migrate to Next.js 16 Cache Components: tag-based caching for WPGraphQL reads #141

Description

@kuuak

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

  • Set cacheComponents: true in next/next.config.ts.
  • Confirm no route exports the deprecated runtime = 'edge' (Cache Components requires the Node.js runtime).
  • Decide what happens to export const revalidate = 3600 in app/[[...uri]]/page.tsx — it is previous-model route config that cacheLife supersedes.
  • Run next build and work the resulting error/insight list.

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.

  • Split into a cached public read — narrow signature, (uri, lang, routePage), no auth, no preview — and an uncached preview read that keeps the token.
  • Cached read: cacheLife('max'), plus cacheTag applied after the fetch so it can use node.databaseId (WordPress knows IDs; the route only knows URIs, and slugs change).
  • Cache 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 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.

  • Add a Next-side getCachedBlockData(name, attributes, lang) wrapper in lib/get-block-final-component-props.ts that calls getData inside the cached scope. name is a serializable handle because blocksDataList[name]() is a lazy import, so every argument crossing the boundary stays plain data.
  • Have blocks return cacheTags in their result; the wrapper applies them via cacheTag. This is inert in the WordPress editor, which reads only the keys it knows.
  • Keep a preview ? getData : getCachedBlockData guard 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.
  • Warn in development when a block returns data with no cacheTags — it silently falls back to the coarse tag and is invalidated by every save.
  • Audit every data.ts for cookies() / headers() reads. Under the wrapper these now throw next-request-in-use-cache, and on a dynamically rendered route that surfaces under next start, not next build.

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

  • Switch app/api/revalidate/route.ts from revalidatePath to revalidateTag(tag, 'max'). The single-argument form is deprecated; 'max' gives stale-while-revalidate.
  • Switch GET → POST, and move the secret from the query string into a header (it currently lands in access logs, browser history and referrers).
  • Drop the self-fetch() warm-up. It existed to spare the first visitor a blocking miss; stale-while-revalidate already does that.
  • Coordinate with the breaking change in superhuit-agency/nextjs-revalidate (see the linked issue) — the plugin currently sends ?path=&secret=.

6. Verify

  • npm run build, then NEXT_PRIVATE_DEBUG_CACHE=1 npm run start. Not next dev — it adds an HMR hash to every cache key, and runtime-only errors don't surface there.
  • Confirm the x-nextjs-cache sequence around a webhook: HIT → fire → STALEHIT with new content. A MISS in the middle means something expired instead of going stale.
  • Confirm preview still bypasses the cache, and that the WordPress editor bundle still builds and shows live draft data.
  • Confirm app/[[...uri]]/page.tsx still produces a static shell — its cookies() call sits inside an if (isDraftModeEnable) branch that shouldn't execute during prerender, but this is the docs' contract, not a measurement.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions