Sanity's Growth tier gives you one million CDN API requests a month. That number feels generous right up until you watch the meter cross 80 percent on day 17 and flatline at 100 percent before the cycle ends. On a client site I maintain, that happened twice in a row: a full month of quota consumed somewhere between day 17 and day 22.
The part that refused to add up: human traffic was minimal. Not "a bit quiet", genuinely small. No campaign, no spike, nothing suddenly shared around. A million requests, against analytics that fit on a single screen. Numbers that size clearly were not coming from people, so the question changed shape: if not visitors, then what was firing all these queries?
The first suspect: bots
My first guess was the obvious one, crawlers. Bots stay invisible to JavaScript based analytics, which makes them a convenient thing to blame. I filed the theory away and, for a while, believed it.
But blaming bots is too easy, and something did not line up. Bots are only a multiplier. If each page view touched the API only occasionally because everything else came from cache, even an aggressive crawl schedule could not burn a million requests. The right question was never who was knocking on the door. It was why every single knock went all the way through to the origin.
Who was actually knocking turned out to be its own story, and not the one I expected. The cache leak came first.
The grep I should have run in minute one
There is one command I now run before anything else whenever an API quota burns with no traffic to explain it:
grep -rn "force-dynamic" app --include="page.tsx"The output landed immediately. Two detail route groups, each carrying hundreds of slugs, both opening with the same line:
export const dynamic = "force-dynamic";That line was not a typo. It usually arrives as a reasonable shortcut: a route misbehaves while Next.js tries to statically optimise it, force-dynamic makes the problem disappear, everyone moves on. What rarely gets noticed is that this one line reaches much further than "do not pre-render".
Why one line got expensive
In Next.js 15 and above, export const dynamic = "force-dynamic" does more than decide how a route renders. It also changes the default caching behaviour of the fetches beneath it. Any fetch in that render tree that carries no explicit cache configuration is treated as cache: "no-store".
The consequences cascade. Nothing lands in the Data Cache, no result is reused across requests, and there is no revalidation window absorbing load. Every render hits the API from scratch as if that data had never been requested before.
Now multiply it out, the way I eventually did on paper:
- hundreds of slugs per route group, times two groups
- several Sanity calls per page, because a detail page typically needs its main document, global settings, and a few supporting blocks
- times however many times those pages get rendered in a month
You do not need heavy traffic to reach seven digits. You just need a cache that is never hit at all.
The fix: set a baseline in one place
Fortunately every Sanity call in that project went through a single central wrapper. That is the kind of architectural luck you only appreciate in a moment like this, because the fix lives in one file, not hundreds of call sites.
export async function sanityFetch<T>(
query: string,
params: Record<string, unknown> = {},
options: SanityFetchOptions = {},
): Promise<T> {
return client.fetch<T>(query, params, {
next: {
revalidate: options.revalidate ?? 300,
tags: options.tags ?? [],
},
});
}The rule is simple: no fetch is allowed to leave without cache options. If the caller says nothing, it still gets a five minute baseline plus tags, and those tags are what later power targeted invalidation when an editor publishes a change.
Step two, on the per-slug pages, swapped the dynamic declaration for ordinary revalidation:
export const revalidate = 300;The result: bleeding stopped, not cured
This fix stopped the worst of the bleeding, which was the goal at the time. Being honest about it though, it did not solve the problem.
The next cycle still showed around 150,000 requests per day. Far saner than before, still far too high for a site with that little human traffic. The five minute baseline had moved the problem rather than removed it, and the remaining cause was nothing like what I assumed.
That part only surfaced once I stopped guessing and started reading the request logs one by one, and that is its own story.
A nuance I only verified afterwards
During the incident I treated force-dynamic as poison to be purged from every route. Once things settled I went through the Next.js source to be sure, and that reaction was an overcorrection. The boundary is exactly what I had seen in production and no wider: only unconfigured fetches collapse into no-store, while an explicit positive revalidate on the fetch is still respected.
That means if you genuinely need force-dynamic for a legitimate reason in your render tree, you are not obliged to give up the Data Cache with it. Just make sure every fetch carries its own next: { revalidate: N }. That combination is valid and still caches. The dangerous pattern is not force-dynamic by itself, it is force-dynamic meeting bare fetches with no options at all.
What I took away
- When an API quota burns without a traffic spike, look for
force-dynamicinapp/**/page.tsxbefore suspecting anything else. It is a five second diagnostic that can save hours. force-dynamicis not only a rendering directive. It pushes ano-storedefault down onto every non explicit fetch below it.- A fetch with no cache configuration is not the neutral choice. Inside a dynamic route it is the most expensive one.
- A single central wrapper for API calls is an investment. When you need to enforce a baseline, you touch one file.
- Explicit always wins.
force-dynamichonours therevalidateyou write yourself, so write it.
