banner image

Publish-Driven Static Sites: Cache-First Delivery with On-Demand ISR

How we built a publish-driven, cache-first architecture where user requests never touch the backend — and content updates still reach the CDN in seconds.

By Marius

Last updated April 3, 2026

At work we've been moving the product and corporate websites from WordPress over to Next.js — a project I took the initiative on and that's still ongoing. It's been a side hustle on top of my regular role, largely motivated by wanting to try out event-based cache invalidation in practice.

From the start I wanted to go all in on a static-content-first approach, even for dynamic content coming from a CMS. That meant building a custom one. Could we have used an off-the-shelf CMS? Probably — most headless CMS platforms support webhooks on publish. But we had a specific workflow in mind: integrated Gemini-powered translations (2 languages for two of the sites, 8 for PowerPay), per-article or bulk on-demand revalidation, and tight control over how content maps to cache tags. Rolling our own let us bake all of that into the publish flow rather than bolting it on after the fact. The core principle: no user request should ever hit the backend. Every page is pre-rendered HTML served from a CDN. But we still need content to update when someone publishes an article. Reconciling those two goals is where things get interesting.

The problem with "just use SSR"

The lazy option would be server-side rendering on every request. User hits a page, Next.js fetches from the database, renders HTML, sends it back. It works, but it's wasteful. The content barely changes — maybe a few times a week — yet every single visitor would trigger the same database query and render cycle. Multiply that across multiple sites and locales and you're burning server resources for no reason.

What we wanted was the performance of a fully static site with the flexibility of a dynamic one. That's the promise of ISR (Incremental Static Regeneration), but specifically the on-demand variant where we control exactly when and what gets regenerated.

Three planes, clear boundaries

The architecture breaks down into three parts, each with a strict responsibility:

CMS (Control Plane) — Where content is authored. Uses Server Components and Server Actions to write to Supabase. This app is only used by internal editors. It never serves public traffic and never reads data on page view — it's a pure write interface.

CMS API (Read Layer) — A read-only API exposed by the CMS. You might wonder why this layer exists at all if everything is static. The answer is that it provides a consistent read model that decouples the consumer sites from the database entirely. The sites never talk to Supabase — they talk to this API, and only at build time and during revalidation. Not on user requests. The consumer sites treat it as a build dependency, not a live service.

Consumer Sites (Delivery Plane) — The public-facing websites. Fully static, fully cached on a CDN. When a user loads a page, the response is pre-built HTML. Zero database queries, zero API calls, zero server-side computation. The user request path is just:

User → CDN → Static HTML (done)

This is the part that I think makes the architecture worth writing about. It's not just "we use static generation" — it's that we've drawn a hard line where the entire runtime is completely decoupled from data access, and every piece of the system respects that boundary.

Three lifecycles, not one

One thing that took a while to click for me is that this architecture has three completely different execution modes, and confusing them is where most misunderstandings come from:

| Phase | What happens | Data fetching? | | ---------------- | -------------------------------------------------------------- | -------------------- | | User request | CDN serves pre-built HTML | None — purely static | | Build time | Next.js fetches all content from CMS API, generates every page | Yes — full fetch | | Revalidation | CMS triggers selective regeneration via webhook | Yes — targeted fetch |

The key insight is that data fetching does happen — just never on the user request path. When I say "no backend calls at runtime," I mean specifically that no user request triggers a fetch. The build and revalidation phases absolutely hit the API, but those are operational processes, not user-facing ones.

The revalidation flow

So if pages are static and the API is never called at runtime, how do content changes reach the sites? This is where on-demand ISR comes in, and getting this flow right is what makes the whole thing work.

When an editor publishes or updates an article in the CMS, the following happens:

  1. Compute affected paths and tags — The CMS figures out which pages need to be regenerated. This could be the article page itself, a listing page, or pages across multiple sites if the article appears on more than one. Cache tags make this granular — we can invalidate "all articles tagged energy" without touching unrelated pages.

  2. Fire webhooks to the consumer sites — The CMS sends a POST request to each affected site's /api/revalidate endpoint, including the paths and/or tags to invalidate plus a shared secret for authentication.

  3. Next.js handles the regeneration — The revalidate endpoint calls revalidatePath() or revalidateTag(). Next.js then fetches fresh data from the CMS API — this is the only moment the API gets called outside of a full build — renders the updated HTML, and replaces the cached version.

  4. CDN cache is updated — The next user to request that page gets the fresh version. No stale content, no manual deploy, no full rebuild.

The entire cycle from "editor clicks publish" to "updated page on the CDN" takes seconds. And crucially, no user request was involved. The regeneration is triggered by the publish event, not by someone visiting the page.

Why on-demand over time-based ISR

Next.js also supports time-based ISR where you set a revalidate interval (e.g., regenerate every 60 seconds). We deliberately chose not to use this. With time-based ISR, users can see stale content for up to the revalidation window, and you're doing unnecessary regenerations even when nothing has changed.

On-demand ISR gives us two things: content is fresh as soon as it's published, and we never waste resources regenerating pages that haven't changed. The tradeoff is that you need the webhook plumbing, but that's a one-time setup cost.

Securing the webhook

The /api/revalidate endpoint is protected by a shared secret (REVALIDATE_SECRET) that both the CMS and the consumer sites know. The CMS includes it in the request, and the endpoint validates it before calling any revalidation functions. Without this, anyone could trigger regeneration — or worse, use it as a way to probe what paths exist on the site.

The monorepo angle

All of this lives in a Turborepo monorepo — the CMS, the consumer sites, and shared packages like the CMS API client and utility functions. The monorepo isn't really the interesting part, but it does make the architecture practical. The CMS client that calls the API is a shared package, so every site uses the same data layer. The revalidation endpoint logic is nearly identical across sites. And when we add a new website, the whole publish → invalidate → regenerate pipeline works out of the box because the plumbing is shared.

├── apps/
│   ├── website-1/          # Consumer website
│   ├── website-2/          # Corporate website
│   ├── website-3/          # Consumer website
│   └── cms/                # Custom headless CMS

├── packages/
│   ├── cms-client/         # Shared API client (used at build + revalidation)
│   ├── utils/              # Markdown loader, helpers
│   └── ...                 # Shared configs (eslint, tsconfig, postcss)

Things that reinforce the architecture

A few of the conventions we follow exist specifically to protect the static-first boundary:

Server Components everywhere — Pages, layouts, and templates are always Server Components. Client components are small leaf nodes for interactive bits. This prevents accidentally pulling in runtime data fetching through a useEffect somewhere in the tree.

No dynamic rendering triggers — We never use cookies(), headers(), draftMode(), or export const dynamic = 'force-dynamic' in the consumer sites. Any of these would silently flip a route from static to dynamic in the build output, and suddenly you have a page hitting the backend on every request. We check the build output ( for static, ƒ for dynamic) to catch this.

Static i18n — The sites support Norwegian and English via next-intl, generating separate static HTML per locale. The pattern is generateStaticParams in the locale layout plus setRequestLocale(locale) called early in every page. I wrote about this in more detail in a previous post.

Where this gets hard

I don't want to make this sound like it's all upside. There are real tradeoffs that come with this approach:

Invalidation complexity — Figuring out which pages to regenerate when content changes is a genuinely hard problem. If an article appears on its own page, a listing page, and a homepage "latest articles" section across two sites and two locales, that's a lot of paths to track. Get it wrong and users see stale content. We've had cases where a tag-based invalidation missed a page because the dependency wasn't obvious.

Webhook failure modes — If the revalidation webhook fails silently (and it has), the CDN keeps serving the old version indefinitely. There's no automatic retry built into this — you need monitoring and alerting around it, which we honestly set up later than we should have.

Regional cache inconsistency — CDN edge nodes don't all update at the same instant. For a brief window after revalidation, users in different regions can see different versions of the same page. For a content site this is usually fine, but it's worth knowing about.

Operational overhead — This is more moving parts than a simple SSR setup. You're maintaining webhook endpoints, shared secrets, dependency mapping logic, and monitoring — all to avoid a database query that would take milliseconds. The payoff is in scale and performance, but for a single low-traffic site, SSR with caching headers might be simpler and totally sufficient.

The takeaway

If you're building content-driven sites and reaching for SSR by default, it's worth asking: does the data actually change between requests? If the answer is "rarely" or "only when someone publishes," you might be overcomplicating things. A publish-driven, cache-first architecture gives you the speed of static sites with the freshness of dynamic ones — and your users will never know the difference. Just go in with your eyes open about the invalidation complexity, because that's where the real work lives.