A blog has an awkward pair of requirements. Readers want pages that load as fast as static files, and editors want a correction to appear seconds after they press publish. Rebuilding the whole site on every edit satisfies the first and fails the second. Rendering every page on every request does the opposite. This post describes how the blog you are reading does both.

The pieces

There are two applications.

  • The site is a Next.js 16 app using the App Router, written in TypeScript. It runs on Cloudflare Workers through OpenNext, the @opennextjs/cloudflare adapter.
  • The CMS is Strapi 5 on PostgreSQL. Editors write there, with draft and publish states and block-based rich text.

Only the site runs on Cloudflare. Strapi is an ordinary Node server in a container, with a managed Postgres behind it. The site reads from Strapi's REST API, and Strapi calls the site back when content changes. Those are the only two connections between them.

Static generation first

At build time, every post, category, tag, author and listing page is pre-rendered. Each dynamic route exports generateStaticParams, which asks Strapi for the list of slugs and renders one HTML page per entry.

A post published after the build is not a problem. Routes set dynamicParams = true, so a slug that was unknown at build time is rendered on its first request and cached like the rest.

The one exception is search. /search?q= depends on the query string, so it is rendered on the server for each request and tells robots not to index it. Everything else is served from cache.

Tag-based revalidation from the CMS webhook

The interesting part is how a cached page learns that it is out of date.

Every read is tagged

All calls to Strapi go through one function in lib/strapi.ts. It passes cache tags to fetch, so Next.js remembers which data each page was built from.

const res = await fetch(url, {
  headers: { Accept: "application/json" },
  next: { revalidate, tags },
});

The tags are a small fixed vocabulary: posts, authors, categories, tags, global, and one per post in the form post:<slug>. A post page is built from reads tagged post:<slug> and posts. A listing page is built from reads tagged posts.

The webhook maps a change to tags

When Strapi boots, a bootstrap step registers a webhook named "Next.js revalidate". It fires on entry create, update, delete, publish and unpublish, and on media create, update and delete. The URL and secret come from environment variables, so the same code works locally and in production.

The webhook posts to /api/revalidate on the site. That route handler does three things.

  1. Checks the secret. The request must carry Authorization: Bearer <REVALIDATE_SECRET>. A request without the secret gets a 401. The value is the same on both sides and is stored as a Worker secret, not in the repository.
  2. Maps the changed model to tags. A post change marks posts, plus post:<slug> for that post. An author, category or tag change marks its own tag and posts, because post pages show those names. A change to the global settings marks global. A media change, or a model the handler does not recognise, marks everything, since an image could be embedded on any page.
  3. Calls revalidateTag for each tag, then revalidates the RSS feed and the sitemap by path.
const MODEL_TAGS: Record<string, string[]> = {
  post: [tags.posts],
  author: [tags.authors, tags.posts],
  category: [tags.categories, tags.posts],
  tag: [tags.tags, tags.posts],
  global: [tags.global],
};

for (const tag of affected) revalidateTag(tag, "max");

Stale while revalidating

revalidateTag is called with the "max" profile. The affected pages are marked stale, not deleted. The next visitor still receives the cached HTML at once, while a fresh copy is rendered in the background. The visitor after that gets the new page.

The effect is that Strapi is called once per edit, not once per visitor, and no reader ever waits on the CMS.

A backstop for missed webhooks

Webhooks can fail: the CMS restarts mid-request, or a deploy is in progress. So every page also sets revalidate = 3600. If a webhook is missed, the page corrects itself within the hour. The webhook makes edits appear in seconds, and the timer makes sure nothing stays wrong for long.

The edge cache

On a traditional Node host, Next.js keeps its incremental cache on local disk. A Worker has no disk, and it runs in many locations at once. OpenNext replaces each part of the cache with a Cloudflare service. The whole configuration is one short file.

// open-next.config.ts
export default defineCloudflareConfig({
  incrementalCache: withRegionalCache(r2IncrementalCache, { mode: "long-lived" }),
  queue: doQueue,
  tagCache: doShardedTagCache({ baseShardSize: 12 }),
  enableCacheInterception: true,
});

Each line answers one question.

  • Where do rendered pages and fetch results live? In an R2 bucket. A regional cache sits in front of it, so most reads never reach R2.
  • What stops a stampede? A queue backed by a Durable Object. When a popular page goes stale and many requests arrive together, it is regenerated once, not once per request.
  • How does a Worker know a tag was invalidated? A tag cache, also on Durable Objects and sharded. This is what makes revalidateTag from the webhook take effect at the edge.

The bindings for the bucket, the two Durable Object classes and static assets are declared in wrangler.jsonc.

Preview bypasses the cache

Editors need to see a draft before it is public, and a cached site is exactly the wrong tool for that.

Strapi's editor has an "Open preview" button. It opens /api/preview on the site with a second shared secret, the path of the post and status=draft. The route checks the secret and turns on Next.js draft mode. While draft mode is on, reads skip the cache entirely (cache: "no-store") and ask Strapi for the draft version. A banner at the top of the page offers "Exit preview".

What we would tell someone copying this

A few lessons from building it, all of which apply beyond this blog.

  • Tag every read in one place. If a single fetch somewhere forgets its tags, that page will not update on a webhook and you will find out from a reader.
  • When unsure, invalidate more. An unknown model refreshes everything. A wasted regeneration is cheap, and a stale page is a visible bug.
  • Authenticate the webhook. An open revalidation endpoint lets anyone force your site to re-render on demand.
  • Run the real Worker locally. wrangler can run the production bundle with R2 and Durable Objects emulated. Cache behaviour differs between the Next.js dev server and the Worker, so that is where it should be tested.

How this relates to AuthFI

This post is about the blog, not the product, and the two are separate systems. The pattern is one we use elsewhere, though: the AuthFI control plane also runs on Cloudflare, while the data it governs stays in a regional plane. We describe that split in keeping identity data in your region.

A shared bearer secret is fine for a cache purge. For calls that carry real authority there are better options, covered in service-to-service authentication.

Key takeaways

  • Pre-render everything with generateStaticParams, and let dynamicParams handle content published after the build.
  • Tag every CMS read, and have the CMS webhook invalidate by tag so only affected pages regenerate.
  • Serve stale HTML while the new page renders, so readers never wait on the CMS.
  • On Workers, OpenNext maps the Next.js cache to R2, a Durable Object queue and a Durable Object tag cache.
  • Pair the webhook with an hourly revalidation as a backstop.