Skip to content

Move FSE snapshot from build artifact to runtime cached data (tag-invalidated) #132

Description

@kuuak

Blocked on nextjs-revalidate v1.7.0 (milestone) — specifically superhuit-agency/nextjs-revalidate#30, which provides the WordPress-side trigger. The Next.js work below is implementable and testable before that release; only the end-to-end acceptance criteria require it.

For the implementing session: this issue is the spec. Follow the next-wordpress-data-flow skill — this changes a data contract crossing the WordPress→Next.js boundary. When done, /code-review against this issue exercises the Spec axis.

Problem

The FSE snapshot — all templates with core/template-part blocks inlined and Polylang translation variants attached — is a build artifact. next/scripts/fetch-fse-templates-and-parts.ts runs via predev/prebuild and writes a JSON file that is imported statically:

// next/src/lib/get-node-by-uri.ts:8
import fseTemplatesData from '@/lib/fse/fse-templates-and-parts.json';

Because the import is static, the JSON is inlined into the compiled server bundle. Rewriting the file at runtime does nothing for a running next start. Editing an FSE template or template part in WordPress therefore requires a full rebuild and redeploy — a CI run and a PM2 process bounce for what is a content edit.

The build also depends on WordPress being reachable: if it isn't, the script writes an empty-templates fallback and the site ships with no templates, silently.

Goal

Make the snapshot runtime cached data, invalidated on demand by a cache tag. Editing a template in WordPress updates the frontend without a rebuild.

Design decisions

These were settled in a design session; recorded here so they are not relitigated.

  1. Snapshot becomes runtime cached data, not a build artifact seeded at build time and not a hybrid of both.
  2. Caching primitive: experimental.useCache: true in next.config.ts + the "use cache" directive + cacheTag('fse-templates'). Not top-level cacheComponents — that changes route-segment caching semantics app-wide, interacts with export const revalidate = 3600 (next/src/app/[[...uri]]/page.tsx:19) and PPR on every route, and is a framework migration in its own right. Not unstable_cache — it works today but is the legacy path in Next 16 and would need migrating again.
  3. Backstop TTL: cacheLife('hours') — stale 5 min / revalidate 1 h / expire 1 day. Chosen to match the page's existing revalidate = 3600 so the snapshot and the full-route cache share one cadence. Purely a safety net for a missed invalidation ping (plugin misconfigured, request failed, secret rotated).
  4. Build hooks removed. The script survives only as a debug CLI writing to a gitignored path outside src/lib/fse/, so it can never be imported again.
  5. Endpoint: a dedicated /api/revalidate-fse route, matching the default path in the plugin's new settings.

Scope

next/next.config.ts

  • Add experimental: { useCache: true }. Narrowly scoped — it only enables the directive.

New: next/src/lib/fse/get-fse-snapshot.ts

  • Extract the body of next/scripts/fetch-fse-templates-and-parts.ts (the two GraphQL queries, formatBlocksJSON with skipGetData: true, template-part inlining, Polylang translations grafting) into an exported async function.
  • Wrap with 'use cache', cacheTag('fse-templates'), cacheLife('hours').
  • Behaviour on WordPress error must degrade the same way the script does today: return an empty-templates structure rather than throwing and taking pages down.

⚠️ Landmine — cache the function, not the fetch. next/src/lib/fetch-api.ts:34 issues fetch(endpoint, { method: 'POST', … }), and Next.js never caches POST fetches. Relying on fetch-level caching would re-query WordPress on every ISR regeneration (~0.9 s measured) instead of once per invalidation. The cached unit must be the finished, template-part-inlined structure.

next/src/lib/get-node-by-uri.ts

  • Delete the static import at line 8 (and the @ts-ignore above it, now unnecessary).
  • getTemplateBlocks() (line 326) becomes async, reading from getFseSnapshot(). Its call site (lines 125–128) already sits inside a Promise.allSettled, so it becomes enrichTemplateBlocks(await getTemplateBlocks(...), lang).
  • The translations[lang] substitution and core/post-content injection are unchanged.

New: next/src/app/api/revalidate-fse/route.ts

  • Validate secret against process.env.REVALIDATE_SECRET, matching the existing next/src/app/api/revalidate/route.ts (same env var, same query-arg convention, same 401 on mismatch).
  • Call revalidateTag('fse-templates').
  • Do not warm. No await fetch(...) fan-out — see Research: cheap cache invalidation vs selective warming for large sites #130. Return a small JSON body consistent in shape with the existing route.

Cleanup

  • Remove predev and prebuild from next/package.json (lines 6, 8).
  • Remove the fse-templates-and-parts.json entry from next/.gitignore (lines 51–52); add the new debug-dump path instead.
  • Reduce next/scripts/fetch-fse-templates-and-parts.ts to a thin CLI calling getFseSnapshot() and dumping to the gitignored debug path.
  • Rewrite docs/fse-templating.md — its entire "Build time" phase, the architecture diagram, the mermaid schema, and the "Updating the JSON" section all become wrong.

Acceptance criteria

  • npm run build no longer contacts WordPress, and succeeds with WordPress unreachable
  • A page renders its FSE template correctly with no JSON file present anywhere
  • GET /api/revalidate-fse?secret=… returns 200; a wrong or missing secret returns 401
  • After hitting the endpoint, a template change in WordPress appears on the frontend without a rebuild
  • Polylang translated template parts still resolve per-language (regression check against the translations map)
  • core/navigation inner blocks are still fetched at request time via getData, not from the snapshot
  • Only one GraphQL round-trip pair occurs per invalidation, not one per ISR regeneration — verified by logging or network capture (this is the POST landmine above)
  • Verify and record: does revalidateTag('fse-templates') invalidate the full-route cache entries of already-prerendered pages, or only the data-cache entry? Answer belongs in Research: cheap cache invalidation vs selective warming for large sites #130 and determines whether path revalidation is additionally required
  • End-to-end against tipee.ch staging with nextjs-revalidate ≥ 1.7.0: edit a template part in the site editor → change appears on the frontend

Reference measurements

tipee.ch against admin-staging.tipee.ch — 702 KB snapshot, 13 templates, 3 template parts, 1163 blocks:

network (2 GraphQL queries, parallel) = 889–1045 ms   ← once per invalidation, site-wide
parse + format (formatBlocksJSON)     = 12–31 ms
JSON.parse(702 KB)                    = 1.8–2.7 ms    ← per ISR regeneration, cache hit
full build script, cold               = 2.13 s wall   ← removed from every build

Expected impact: visitor requests unchanged (ISR full-route cache); ISR regeneration gains ≤ 2.7 ms against a render that already makes several WordPress round-trips for getData; builds get ~2.1 s faster and lose their WordPress dependency.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions