On-demand revalidation: TYPO3 publishes, Next.js refreshes

Invalidate exactly the pages an editor changed — no deploy, no full rebuild, and the rest of the site stays cached.

Nikolai Townsend — production notes from this site. Stack: TYPO3 13.4, EXT:headless, Next.js 16.

This frontend renders fully static: every page is served straight from the cache, and no request touches the backend. Caching that strict needs one thing to stay honest: a targeted way to clear the cache when content changes — otherwise an editor publishes and the site keeps serving yesterday's page. The pattern that solves this: publishing fires a webhook, the frontend invalidates just the affected pages. For TYPO3 + Next.js I found no write-up of that pattern, in English or German. So here is the whole machine as it runs on this site: a DataHandler hook that collects what changed, one HTTP call to a Next.js route handler, and a verification step that turns a fire-and-forget webhook into a result you can trust.

Why a static frontend goes stale

The setup this article builds on: Next.js fetches one JSON document per page from TYPO3, every CMS fetch uses cache: 'force-cache', and the catch-all route is force-static. That combination is what makes the frontend fast and the backend quiet — and it caches aggressively on two levels. The rendered page lands in the full route cache, and the TYPO3 JSON lands in Next's Data Cache, which even survives a rebuild.

The consequence: an editor publishes in TYPO3, TYPO3's own cache is fresh — and the site keeps serving the old content, because nothing tells Next.js to drop its copy. A full rebuild would flush it, but coupling editing to deployment is a stopgap, not a workflow. Caching this hard needs a targeted invalidation path (the underlying setup is described in the TYPO3 headless with Next.js guide) — on-demand revalidation is that path.

The pattern: publish, webhook, revalidate

Three moving parts. TYPO3 notices what changed and sends one POST to the frontend. A Next.js route handler authenticates it and invalidates the affected cache entries. The next request re-renders those pages from fresh JSON. TYPO3's own caches are never touched — this is purely an outbound call.

The contract between the two sides is small enough to read in full:

POST /api/revalidate
x-revalidate-secret: (shared secret, from env on both sides)

{ "tags": ["menu"], "slugs": ["/service/ki"] }   // targeted
{ "scope": "all" }                               // everything

// Response, always 200 when the secret is valid:
{ "revalidated": ["menu", "/service/ki"],
  "verified": [ { "slug": "/service/ki", "status": 200, "ms": 340 } ] }

Two invalidation mechanisms, used for different things: tags handle the cross-cutting fetches (menu, footer, a global tag that every fetch carries for the “refresh everything” case), slugs handle individual pages. Why not a tag per page? Because the catch-all route cannot know its page ID at fetch time — the slug is all it has. Per-page invalidation is therefore path-based via revalidatePath(); the page IDs stay a backend concern.

1. The TYPO3 side: one service, two triggers

Everything TYPO3-side lives in a small extension (nito_revalidate on this site): a service that makes the HTTP call, a DataHandler hook that feeds it, and an entry in the backend's clear-cache menu for a full refresh.

The hook collects — and fires once

A single backend action can touch dozens of records (mass edit, copy with children). Firing a webhook per record would hammer the frontend, so the hook only collects affected page IDs and sends one call when the DataHandler is done:

public function processDatamap_afterDatabaseOperations(
    string $status, string $table, string|int $id,
    array $fieldArray, DataHandler $dataHandler
): void {
    $uid = $status === 'new' ? $dataHandler->substNEWwithIDs[$id] : (int)$id;
    $this->collectPageUid($table, $uid, $dataHandler);
}

// Called by the DataHandler at the very end — one POST for the whole run
public function processDatamap_afterAllOperations(DataHandler $dataHandler): void
{
    $this->flush();
}

For pages records the page ID is the record itself; for tt_content and friends it is the pid. At flush time the collected IDs become tags, and the page slugs are queried fresh from pages.slug — not taken from the incoming field array, which is empty for content changes and holds the old value during a slug change.

The service must never break saving

The HTTP call is synchronous inside a backend save. If the frontend is down or slow, the editor must not pay for it — so the call gets a hard timeout and a catch-all:

$response = $this->requestFactory->request($url, 'POST', [
    'headers' => [
        'Content-Type' => 'application/json',
        'x-revalidate-secret' => $secret,
    ],
    'json' => ['tags' => $tags, 'slugs' => $slugs],
    'timeout' => 5,
]);

The whole thing is wrapped in try/catch (\Throwable): a failure is a log entry, never an exception that reaches the DataHandler. With Next.js completely down, the failed call costs about two milliseconds and the record still saves. If the synchronous call ever becomes a problem, the service body can move behind Symfony Messenger without touching hook or controller — but start simple.

2. The Next.js side: the revalidate route

The frontend counterpart is one deliberately dynamic route handler — no caching, POST only, fail-closed on the secret (an unset env var returns 500, never an open endpoint):

// app/api/revalidate/route.js
import { revalidatePath, revalidateTag } from 'next/cache'

export async function POST(request) {
  const secret = process.env.REVALIDATE_SECRET
  if (!secret) return Response.json({ error: 'Not configured' }, { status: 500 })
  if (request.headers.get('x-revalidate-secret') !== secret) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 })
  }

  const { tags = [], slugs = [], scope } = await request.json()
  if (scope === 'all') tags.push('all')

  tags.forEach((tag) => revalidateTag(tag))
  slugs.forEach((slug) => revalidatePath(slug))

  const verified = await verify(scope === 'all' ? ['/'] : slugs.slice(0, 3))
  return Response.json({ revalidated: [...tags, ...slugs], verified })
}

For the tags to bite, the CMS fetches have to carry them. Every cached fetch keeps its cache: 'force-cache' and additionally declares next: { tags: […] }: all of them carry the global all tag, the menu fetch also carries menu, the footer tag cloud tag-cloud. A page-title change then refreshes the navigation on every page with a single revalidateTag('menu') — no enumeration of routes anywhere.

3. Making the result mean something

Here is the part most write-ups skip: revalidateTag() and revalidatePath() are void. They mark cache entries as stale; the actual re-render happens lazily on the next request. Worse, since Next.js 16 tag revalidation is “eventual” — the first visitor after an invalidation may still see the old page while the refresh runs in the background. And a tag that no fetch carries is a silent no-op. A plain 200 OK therefore confirms only “invalidation executed”, not “the page is fresh”.

The fix is cheap: after invalidating, the route handler fetches each affected page once itself, server-internally (http://127.0.0.1:3000 + slug — same process, no detour through the public edge). That self-request is the first hit after invalidation: it triggers the re-render eagerly, consumes the potentially stale first response, and yields a real per-page result — HTTP status and duration — that goes into the verified array. The editor who saves and reloads sees the new content on the first reload, not the second.

Three deliberate limits keep this honest and fast. The handler verifies at most three slugs per call so the whole round trip stays inside the backend's five-second timeout — further pages are still invalidated, just not eagerly rendered. A failed verification (backend unreachable, broken JSON) shows up as a non-200 entry inside the response body, while the HTTP status stays 200 — an editor's save must never look broken because a health check failed. And for scope: "all" there is no site-wide confirmation — that would be a crawl, which is what the deploy rebuild is for; the handler verifies the front page as a sample and leaves it at that. TYPO3 logs the response body, so the verified entries end up in the backend log as a diagnosis trail.

Pitfalls from taking this to production

Workspace publishing does not look like saving

If your editorial flow goes through workspaces, the publish arrives at the hook as a command-map version command — not as a datamap save. Cover that command explicitly, or the one action that should revalidate is the one that never does. The inverse trap exists too: tooling that writes drafts under a live-workspace backend user (our MCP server does) routes changes into versioned records with t3ver_wsid > 0. Skip those in the hook — otherwise every draft save fires a revalidation for content that is not live yet. With both guards in place, drafts stay silent and a publish fires exactly once.

The clear-cache menu has opinions

The button for a manual full refresh belongs in the backend's clear-cache menu — with two rules the docs don't spell out. Titles and descriptions must be LLL: language-file references: the toolbar pipes them through a translation ViewHelper that throws on plain strings, taking every backend page with the menu down with it. And the endpoint behind the button must always answer 200 with its own {success, title, message} body — the toolbar shows its generic “could not clear caches” error on any other status, which is misleading for an action that never touched a TYPO3 cache in the first place.

An open todo: scheduled visibility

Scheduled visibility (starttime/endtime) flips without any DataHandler operation, so no webhook fires at the switch moment. That applies to pages and to individual content elements alike — an element scheduled for Monday 08:00 goes live in TYPO3, but nothing tells Next.js to re-render the page it sits on. The fix is on my todo list: a scheduled task that knows the upcoming switch times and fires the same revalidation call once they pass. A slug change, by contrast, invalidates cleanly — the old URL then needs a redirect, which is an SEO concern, not a caching one.

Until that lands, the gap is covered by a mechanism this setup keeps anyway: every code deploy deletes the build directory and rebuilds the site from scratch, every page freshly rendered from current TYPO3 JSON. On-demand revalidation is the content path; the deploy rebuild is the safety net.

Kontakt

Klingt nach Ihrem Projekt?

Sprechen wir unverbindlich darüber.

Antwort innerhalb 24h — in der Regel deutlich schneller.