
Nextjs fetch function
Every Next.js developer meets fetch on day one, and most of them assume it behaves exactly like the fetch they already know from the browser. It doesn't. On the server, Next.js quietly extends the Web fetch() API with its own caching semantics, and if you don't know that extension exists, you'll either get stale data you didn't ask for or a wall of duplicate network requests you can't explain. This article is the reference for that extension: every option it adds, what each one actually does under the hood, and the mistakes that come from treating server-side fetch like its browser counterpart.
This is deliberately narrow in scope. If you want the bigger picture — how fetching fits into Server Components, streaming, and the rest of the data layer — this blog already covers that in "Fetching Data" and "Caching and Revalidating (Previous Model)". This article assumes you've been using fetch for a while and want to understand precisely what it's doing.
What Next.js Actually Changes About fetch
In the browser, the cache option on a fetch() call controls how the request interacts with the browser's HTTP cache — the same mechanism that decides whether a resource comes from disk cache, memory cache, or the network. Next.js takes that same option and repurposes it, on the server, to control how the request interacts with the framework's persistent cache — the same cache that backs Incremental Static Regeneration and the rest of the App Router's data layer.
That's the single most important thing to internalize: the option name is identical, but the cache it's talking to is completely different depending on where the code runs. A fetch call with cache: 'force-cache' inside a Server Component has nothing to do with what Chrome DevTools shows you in the Network tab — it's asking Next.js's server-side cache, which lives independently of any browser, to remember the response.
You call it exactly the way you'd expect, with async/await inside a Server Component:
// app/page.tsx
export default async function Page() {
const data = await fetch("https://api.vercel.app/blog");
const posts = await data.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Because it's built on top of the standard Web fetch() API, every native option still works — headers, method, body, signal, all of it. Next.js just adds three more options on top: cache, next.revalidate, and next.tags.
The cache Option
fetch(`https://...`, { cache: "force-cache" | "no-store" });
There are effectively three states here, though only two are literal values you pass:
Default (auto no cache) — if you don't set cache at all, Next.js fetches from the remote server on every request while you're running next dev, but during next build it fetches once, because the route gets statically prerendered. If the route contains any Request-time APIs (things like reading cookies() or headers(), which force per-request rendering), Next.js fetches on every request in production too, since there's no static shell to bake the response into.
This default trips people up constantly, because its behavior literally depends on what else is happening in the route. Add a cookies() call somewhere in the same request and your "static" fetch quietly starts running on every request, with no change to the fetch call itself.
no-store — always fetch from the remote server, every request, unconditionally. This is the option to reach for when a route needs Request-time behavior but doesn't otherwise trigger it (no cookies(), no headers(), nothing that would force dynamic rendering on its own) and you still want fresh data every time.
force-cache — look for a matching entry in Next.js's server-side cache first. A match is determined by URL, method, headers, and body together, so two requests that differ in any of those are cached as entirely separate entries. If there's a fresh match, it's returned from cache with zero network activity. If there's no match, or the match is stale, Next.js fetches from the remote server and writes the result into the cache — but only if the response comes back with a 200 status code. A 404 or a 500 is never cached, which is worth remembering if you're debugging "why did this error respond instantly the second time" — it didn't, it just failed fast for an unrelated reason.
One detail the docs are easy to skim past: caching is opt-in by design, and that opt-in extends even to methods you'd normally assume are never cacheable. Set cache: 'force-cache' on a POST request, or on a request carrying an authorization or cookie header, and Next.js will cache it anyway. Nothing stops you from accidentally caching a per-user authenticated response and serving it to the next visitor who happens to trigger the same URL/method/header/body combination. If you're fetching anything user-specific, don't reach for force-cache reflexively just because it sounds like the "fast" option — think through whether the cache key (URL + method + headers + body) actually varies per user, because if it doesn't, you've built a cross-user data leak.
Draft Mode is the one blanket exception: while Draft Mode is active, fetch caching is bypassed entirely — no reads, no writes — so editors previewing unpublished content always see current data regardless of what cache option you've set.
The next.revalidate Option
fetch(`https://...`, { next: { revalidate: false | 0 | number } });
This is time-based cache invalidation, expressed in seconds:
false— cache indefinitely (semantically the same asrevalidate: Infinity). The underlying HTTP cache can still evict old entries under memory pressure, so "indefinite" means "until something else pushes it out," not a hard guarantee.0— never cache this request. Functionally similar tocache: 'no-store', expressed through the other option instead.- A number — cache the response for at most that many seconds before the next request to the same URL triggers a background revalidation.
Two interaction rules matter here and are easy to get wrong in a codebase with more than one contributor:
If an individual fetch() call sets a revalidate number that's lower than the route's own segment-level revalidate default, the entire route's revalidation interval drops to match the lower number. One fetch call with revalidate: 30 buried three components deep can silently shorten the effective cache lifetime of the whole page, even if every other fetch on that page was configured for an hour.
If two fetch calls in the same route hit the same URL but specify different revalidate values, the lower value wins for both. This is the kind of bug that only shows up once your codebase has grown enough that two different developers touch the same endpoint from two different components, each assuming their number is the one that applies.
And you cannot mix models: { revalidate: 3600, cache: 'no-store' } is a contradiction Next.js won't try to resolve — both options are ignored, and in development you'll get a terminal warning telling you so. In production, that same conflict is silently dropped, which is worse, because you won't see the warning that would have told you your configuration wasn't doing anything.
The next.tags Option
fetch(`https://...`, { next: { tags: ["collection"] } });
Tags are the on-demand counterpart to time-based revalidation. Instead of waiting for a timer, you attach one or more string tags to a cached fetch, and later call revalidateTag('collection') from a Server Action or Route Handler to invalidate every cache entry carrying that tag, immediately, regardless of how much time is left on its clock.
The limits are worth knowing because they'll bite you at scale rather than in a demo: a tag can be at most 256 characters, and a single fetch call can carry at most 128 tags. If you're building tags dynamically (say, tagging a fetch with one tag per related entity ID), 128 is a ceiling you can hit sooner than you'd expect on a request that touches a lot of related records — plan your tag granularity around that limit rather than discovering it in production.
Memoization vs. Persistent Caching — the Distinction Everyone Confuses
This is the part of the fetch story that causes the most confusion, because two completely different mechanisms both make repeated fetch calls "fast," for entirely different reasons and on entirely different timescales.
Memoization happens automatically, for any GET request with the same URL and options, within a single server render pass. If the same fetch call appears in three different Server Components that all end up rendering for the same request — a layout, a page, and a nested component, say — Next.js executes it once and shares the result across all three. You get this for free; there's no option to configure, and (crucially) there's no cross-request persistence. The moment that render pass finishes, the memoization is gone.
Persistent caching — everything covered above, controlled via cache and next.revalidate — is what survives across requests. It's a completely separate mechanism from memoization, and the two operate at different layers: memoization deduplicates identical calls within one render, caching persists a result across many renders over time.
The practical consequence: memoization means you can call the same fetch from multiple places in your component tree without worrying about redundant network calls within a single request, without needing to lift that fetch up into a shared parent and thread the result down as props. Caching, separately, means the response might not even hit the network on the next request.
To opt a specific call out of memoization, pass an AbortController signal:
const { signal } = new AbortController();
fetch(url, { signal });
And memoization has one hard boundary worth remembering: it does not apply inside Route Handlers, because they aren't part of the React component tree that a render pass walks. If you're calling the same external API from multiple places inside a Route Handler, you're on your own for deduplication — a single call executed once at the top of the handler and reused, or a small in-memory memoization helper of your own.
Common Patterns
A static list that rebuilds periodically:
// app/blog/page.tsx
export default async function BlogPage() {
const res = await fetch("https://api.vercel.app/blog", {
next: { revalidate: 3600, tags: ["blog-posts"] },
});
const posts = await res.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
This gives you an hourly refresh baseline, but also a tag you can use to force an immediate update the moment a new post is published, via revalidateTag('blog-posts') in whatever Server Action or webhook-triggered Route Handler handles publishing.
A request that must always be fresh, without forcing full dynamic rendering elsewhere in the route:
async function getLiveInventoryCount(sku: string) {
const res = await fetch(`https://api.example.com/inventory/${sku}`, {
cache: "no-store",
});
return res.json();
}
Mixed cache lifetimes in the same route — perfectly valid, and a common real-world shape:
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [product, reviews] = await Promise.all([
fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 86400, tags: [`product-${id}`] },
}).then((r) => r.json()),
fetch(`https://api.example.com/products/${id}/reviews`, {
next: { revalidate: 60, tags: [`reviews-${id}`] },
}).then((r) => r.json()),
]);
return <ProductView product={product} reviews={reviews} />;
}
Product data that changes rarely gets a day-long cache; reviews that accumulate constantly get refreshed every minute. Remember the earlier interaction rule, though — if either of these requests targets a URL that's also fetched elsewhere in the same route with a different revalidate value, the lower of the two applies to both call sites.
Common Mistakes
Assuming cache: 'force-cache' is safe by default for anything that "feels static." If the request carries auth headers or a body that varies per caller, "static-feeling" doesn't mean the response is actually shared-safe. Check what's in the cache key (URL, method, headers, body) before opting into caching.
Fighting the default instead of understanding it. The auto no cache default isn't a bug — it's designed to fetch fresh in development (so you see your changes immediately) and once at build time in production (so static routes stay fast), switching to per-request only when the route is already dynamic for other reasons. If a route you expected to be static is suddenly fetching on every request, look for a cookies(), headers(), or other Request-time API call somewhere in that route before assuming the fetch config is wrong.
Setting conflicting options and not noticing. { revalidate: N, cache: 'no-store' } fails silently in production. If a fetch call isn't behaving the way its options suggest it should, check for a contradiction like this one first — it's a five-second check that saves an hour of cache debugging.
Tagging inconsistently. If two fetches for conceptually the same resource use different tag strings (a typo, a pluralization mismatch, an ID formatted two different ways), revalidateTag will only invalidate the one it matches, and the other will keep serving stale data with no error to tell you why. Centralize your tag-naming in one function or constant rather than typing tag strings inline at each call site.
Development-Mode Quirks
The HMR cache can hide "no-store" from you. In local development, Next.js caches fetch responses in Server Components across Hot Module Replacement refreshes — including requests using the default auto no cache and even cache: 'no-store' — purely to make dev-mode iteration faster and cheaper against billed APIs. That means editing a component and seeing the HMR refresh apply doesn't necessarily mean you're looking at a fresh fetch; the response might be served from the HMR cache rather than the network. It clears on full navigation or a full-page reload, so if you ever suspect you're chasing a caching ghost that isn't real in production, do a hard reload before you start debugging. This behavior is configurable via serverComponentsHmrCache if you'd rather turn it off entirely while developing.
Hard refreshes bypass everything, including your explicit config. If a request arrives carrying a cache-control: no-cache header — which browsers add automatically when DevTools has caching disabled, or during a hard refresh — Next.js ignores cache, next.revalidate, and next.tags entirely for that request and serves straight from source. This is a development-mode behavior specifically; don't be alarmed if a hard refresh in dev "ignores" your revalidate setting — it's working as intended, and production traffic doesn't behave this way.
fetch vs. unstable_cache vs. 'use cache'
Since Next.js gives you more than one caching mechanism, it's worth being explicit about when the extended fetch is the right tool rather than reaching for something else. Use fetch's built-in options when the actual operation you're caching is an HTTP request — hitting a CMS, a third-party API, an internal microservice. The caching lives right where the network call happens, which keeps the code simple. For everything that isn't a raw HTTP call — a database query, a computed value derived from several sources, an expensive synchronous calculation — reach for unstable_cache (in the older caching model) or the 'use cache' directive (in the newer Cache Components model) instead, since fetch's cache option only ever applies to fetch calls themselves, not to arbitrary functions.
Key Takeaways
| Option | What it controls | Common gotcha |
|---|---|---|
cache: 'auto no cache' (default) | Fresh in dev, once at build, per-request if the route is already dynamic | A stray cookies()/headers() call elsewhere silently makes this per-request |
cache: 'no-store' | Always fetch fresh, unconditionally | Doesn't force the rest of the route to render dynamically by itself |
cache: 'force-cache' | Cache by URL + method + headers + body; only 200 responses are stored | Easy to accidentally cache per-user responses if the cache key doesn't actually vary |
next.revalidate | Time-based cache lifetime, in seconds | Lowest value across duplicate URLs/route wins — one call can shrink the whole route's cache |
next.tags | On-demand invalidation via revalidateTag | Max 256 chars per tag, 128 tags per call; inconsistent tag strings silently break invalidation |
| Memoization | Automatic, per-render, GET-only dedup | Doesn't persist across requests, and doesn't apply inside Route Handlers |
The extended fetch is genuinely simple once the mental model clicks: it's the same Web API you already know, plus a persistent server-side cache layered on top through three extra options, plus a separate, automatic, render-scoped memoization layer that has nothing to do with that cache. Most of the confusion people run into comes from conflating those two layers, or from assuming a browser mental model applies to a request that never touches a browser at all.


