← All articles

[ Blog ]

July 9, 2026

11 min read

Why Next.js Keeps Hitting Your CMS on Every Request

Why does Next.js keep hitting my CMS on every request? A first-hand fix for App Router's uncached fetch, force-dynamic refetching, and a runaway CMS bill.

Next.jsCachingISRCMSApp RouterPerformance
Why Next.js Keeps Hitting Your CMS on Every Request

I once watched a low-traffic content site's CMS usage graph climb into an illustrative six-figure count of API calls a month — for a site that maybe got a few thousand visits. If you've asked yourself why does Next.js keep hitting my CMS on every request, you've probably already found the answer everyone repeats: "add caching." What nobody tells you is which two things have to be true at once for that caching to actually kick in, and the one build error that will stop you cold once you try to fix it properly. This is the first-hand walkthrough of both.

Why does Next.js keep hitting my CMS on every request

The short version: since Next.js 13's App Router, and confirmed through 15 and 16, fetch is not cached by default. That's a deliberate reversal from the Pages Router era, where fetch inside getStaticProps was cached implicitly. In the App Router, every fetch call is opt-in to caching — if you don't explicitly tell it to cache, it re-runs on every single request, hitting your CMS's API fresh every time. On a homepage that gets crawled, prefetched, and re-rendered constantly, that adds up fast.

The trap is that this looks like it's working. You add next: { tags: ['prismic'] } to your fetch options because a webhook needs to purge that content later, ship it, and move on. The tag is there. The revalidation webhook is wired up. And yet your CMS dashboard keeps climbing like nothing changed — because a tag alone doesn't cache anything.

The two causes, stacked

In my case it wasn't one bug, it was two, compounding:

  1. Tags without revalidate do not enable caching. next: { tags: [...] } only labels a cache entry for later invalidation — it says nothing about whether the entry should exist in the first place. Without a revalidate value (or cache: 'force-cache'), Next.js treats the fetch as dynamic and skips the Data Cache entirely.
  2. A force-dynamic route re-renders and re-fetches on every hit. Somewhere in the route's history, someone added export const dynamic = 'force-dynamic' — usually to get a per-request signed token or read searchParams reliably. That flag doesn't just affect the render; it drags every fetch inside that route down with it, defeating the Data Cache on the route's hottest page.

Stack those two on your homepage — the route everyone hits, including every crawler and every prefetch — and you've built a very efficient way to burn through a CMS's rate limits.

Fix 1: turn on the Data Cache with revalidate + tags

The fix for cause #1 is one field. Give the fetch a revalidate window and it stops being dynamic-by-default:

// lib/cms/client.ts
export async function fetchCmsDocument(id: string) {
  const res = await fetch(`${CMS_API_URL}/documents/${id}`, {
    headers: { Authorization: `Bearer ${CMS_TOKEN}` },
    next: {
      revalidate: 3600, // seconds — this is what actually enables caching
      tags: ['prismic'], // this only labels the entry for on-demand purge
    },
  });

  if (!res.ok) throw new Error(`CMS fetch failed: ${res.status}`);
  return res.json();
}

revalidate: 3600 puts the response in Next's shared Data Cache for an hour; tags just gives you a handle to purge it early. Neither does the other's job — you need both if you want time-based freshness and instant purge-on-publish.

For the instant purge, wire your CMS's publish webhook to call revalidateTag:

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ revalidated: false }, { status: 401 });
  }

  // Next.js 16: revalidateTag now takes a required 2nd argument —
  // the cache profile ('max' clears the entry outright).
  revalidateTag('prismic', 'max');

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

That second argument tripped me up the first time I upgraded — on Next.js 15, revalidateTag('prismic') was the whole call. On 16, omitting the profile either no-ops or throws, depending on your version, and the tag silently stops purging. If your revalidateTag "isn't purging" after an upgrade, check the arity before you check anything else.

Fix 2: replace force-dynamic with ISR

Cause #2 needs a different fix, because revalidate on the fetch doesn't save you if the whole route is forced dynamic. Look at the route segment config:

// app/(site)/page.tsx — BEFORE
export const dynamic = 'force-dynamic';

export default async function HomePage() {
  const content = await fetchCmsDocument('homepage');
  // ...
}

If the only reason for force-dynamic was "the page reads live data," and that data is really just the CMS content with a long-enough TTL, you don't need the whole route to be dynamic — you need ISR:

// app/(site)/page.tsx — AFTER
export const revalidate = 3600; // keep this in sync with the fetch's revalidate

export default async function HomePage() {
  const content = await fetchCmsDocument('homepage');
  // ...
}

export const revalidate = N puts the route on Incremental Static Regeneration: it's served from a cached render, refreshed at most every N seconds, and any fetch inside it benefits from the Data Cache regardless of whether the route also reads searchParams or cookies for other purposes. This one line was the bigger lever for me — the homepage was the single hottest route, and it alone accounted for most of the CMS traffic once I isolated it.

The gotcha: revalidate must be a literal

Here's the one that cost me a broken build. I wanted to keep the CMS's TTL and the route's TTL in sync, so I pulled both from a shared constant:

// constants.ts
export const CMS_REVALIDATE_SECONDS = 3600;

// app/(site)/page.tsx
import { CMS_REVALIDATE_SECONDS } from '@/constants';
export const revalidate = CMS_REVALIDATE_SECONDS; // fails the build

That fails at build time with something like:

Unknown identifier "CMS_REVALIDATE_SECONDS" at "revalidate" ...
Invalid segment configuration export.

Route segment config exports (revalidate, dynamic, fetchCache, and friends) are read statically by Next's compiler, before your module graph even runs — it can't resolve an imported identifier, only a literal it can parse directly out of the file. The fix is to inline the number and keep it in sync manually, with a comment pointing at the source of truth:

// app/(site)/page.tsx
export const revalidate = 3600; // keep in sync with CMS_REVALIDATE_SECONDS in constants.ts

export default async function HomePage() {
  const content = await fetchCmsDocument('homepage');
  // ...
}

It's a small annoyance, but it's a build-time failure, not a silent bug — which is honestly the better failure mode. You find out immediately instead of six weeks later on a usage graph.

Bonus: collapse repeated hits at the edge

The Data Cache fix stops Next.js from re-hitting the CMS, but route handlers that read request.url or dynamic search params still re-run on every request — they just don't re-fetch from origin anymore. If that route handler gets hammered directly (bots, retries, a flood of identical requests), add a CDN-level cache header to collapse duplicates before they even reach your function:

// app/api/products/route.ts
export async function GET(request: Request) {
  const data = await fetchCmsDocument('products');

  return Response.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=3600',
    },
  });
}

This doesn't reduce CMS calls by itself — the Data Cache fix already did that — but it cuts function invocations and adds real resilience against traffic spikes, since the CDN starts serving cached responses instead of invoking your function at all.

force-dynamic vs ISR + Data Cache

force-dynamicISR (revalidate = N) + Data Cache
Route renderEvery request, freshCached, refreshed at most every N seconds
fetch inside routeSkips Data Cache too, unless explicitly force-cacheCached automatically when it sets next.revalidate
CMS loadOne hit per visitor/crawler/prefetchOne hit per N-second window, regardless of traffic
On-demand freshnessN/A (always fresh, always expensive)revalidateTag() from a publish webhook
Right forTruly per-request data: auth state, live pricesCMS content, marketing pages, anything with a sane TTL

If you genuinely need per-request behavior — a signed token that must be minted fresh, a personalized response — keep force-dynamic, but move the CMS fetch calls into a cached helper (or a parallel request) rather than letting the route's dynamism drag them down with it.

How to verify the fix actually shipped

Don't trust the code review — trust the build output. Run your production build and look at the route table:

npm run build

Before the fix, the homepage row looks like this:

Route (app)                    Size  First Load JS
┌ ƒ /                          142 B         87.3 kB

The ƒ means Dynamic — rendered fresh on every request, server-side, no caching. After adding export const revalidate, the same row should read:

Route (app)                    Size  First Load JS
┌ ○ /                          142 B         87.3 kB
     ├ Revalidate: 1h

is Static, and the new Revalidate column confirms Next.js is now treating it as ISR. That flip — ƒ to , with a Revalidate line appearing — is the compile-time proof the fix landed. What it can't tell you is whether the CMS bill actually dropped; for that you need 24 to 48 hours of production traffic and a look at your CMS's own usage dashboard (or your platform's function-invocation logs) before and after. The build output tells you the fix is possible; production tells you it worked.

FAQ

Does Next.js 15 cache fetch by default?

No. Since the App Router's introduction (carried through 15 and 16), fetch requests are uncached unless you explicitly opt in with next: { revalidate: N } or cache: 'force-cache'. This is the opposite of the old Pages Router default, and it's the single most common reason a migrated app's backend usage spikes after the move.

Why does force-dynamic cause a route to refetch on every request?

export const dynamic = 'force-dynamic' forces the whole route segment to render fresh per request, and that dynamism extends to every fetch inside it — even ones with their own revalidate set can behave inconsistently, depending on version and other segment config. The safe rule: don't reach for force-dynamic unless you genuinely need per-request rendering; use ISR (export const revalidate) for anything with a tolerable TTL, including most CMS-backed pages.

Why isn't revalidateTag purging my cache?

Two common causes. First, on Next.js 16, revalidateTag takes a required second argument (a cache profile, e.g. 'max') — calling it with just the tag name, the Next.js 15 signature, can silently fail to purge. Second, the tag has to match exactly what you passed into the original fetch's next.tags array; a typo or a namespaced tag mismatch means the purge call finds nothing to invalidate.

ISR or force-dynamic for CMS-backed pages?

ISR, almost always. CMS content changes on a human publishing cadence, not a per-request one, so a revalidate window (backed by revalidateTag for instant purge on publish) gives you fresh-enough content without paying for a fresh render and a fresh CMS call on every visit. Reserve force-dynamic for genuinely per-request data — session state, live pricing, personalization — and keep it off routes that are mostly serving CMS content.

Why does my build fail with "Invalid segment configuration export"?

Because export const revalidate (and the other route segment config exports) must be a static literal that Next's compiler can read directly from the file — no imported constants, no computed expressions. If you see Unknown identifier "X" at "revalidate", inline the number and add a comment linking back to wherever else that value is defined, so the two stay in sync by convention instead of by import.


None of this is exotic — it's two defaults changing between Pages Router and App Router, plus one compiler constraint that only shows up when you try to be clever about keeping numbers in sync. But it's an easy set of defaults to miss, and the cost shows up somewhere you're not watching: a CMS invoice, not a Lighthouse score. If you're auditing a site for exactly this kind of quiet cost — caching gaps, slow requests that are really slow infrastructure, or Core Web Vitals regressions on an animation-heavy build — that kind of performance and cost audit is part of the frontend and App Router work I take on. And if you're weighing a headless CMS migration in the first place, this walkthrough of a WordPress-to-Next.js headless stack covers the architecture decisions that make caching like this straightforward instead of an afterthought.