Next.js App Router Data Fetching and Caching: A Practical Guide
A practical guide to data fetching and caching in the Next.js App Router: the flipped fetch default in Next.js 15 and 16, force-cache vs no-store vs revalidate, on-demand revalidation, parallel fetching, Suspense streaming, and the opt-in Cache Components model.

Yash Nandvana
Full Stack Developer

1. Introduction
If you learned Next.js data fetching from a tutorial written for version 14, half of what you know is now wrong — and nothing will warn you. The code still compiles, the page still renders, and the data is just quietly stale, or quietly fetched on every single request.
The reason is a flipped default. In Next.js 14, a bare fetch() inside a Server Component was cached indefinitely unless you opted out. In Next.js 15 and 16, that same fetch() hits the network on every request unless you opt in. The framework moved from "cache everything, opt out when you need fresh data" to "fetch fresh by default, opt in to caching when you want it."
This guide covers the model as it actually works in Next.js 15 and 16: where to fetch, what is cached and what is not, how to opt in deliberately, how to revalidate, and the mistakes that make pages slow or stale. It is written against the current docs and the behavior you can verify with next build followed by next start.
2. Fetch on the Server, Close to the Data
In the App Router, a page or layout is a Server Component by default, so it can be an async function that awaits a fetch call, a database client, or an ORM directly. There is no getServerSideProps and no getStaticProps — the component itself is the data-fetching layer:
export default async function ProductsPage() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.title}</li>
))}
</ul>
);
}Fetching on the server has three practical advantages over fetching in useEffect on the client. The request goes from your server to the API, not from the visitor's browser, so it is faster and the credentials never ship to the client. The data is in the HTML on first paint, so there is no loading spinner followed by a content jump. And the component can stream: slow data can sit behind a Suspense boundary while the rest of the page renders, which section 8 covers.
Two rules follow from this. Fetch in Server Components by default, and reach for client-side fetching only for data that genuinely depends on browser interaction — a search-as-you-type box, a filter the user controls, or data that changes while the page is open. Everything else belongs on the server.
3. The Default That Changed
This is the table to check whenever a tutorial, a Stack Overflow answer, or an old codebase says something is cached that is not — or the reverse:
| Behaviour | Next.js 14 | Next.js 15 | Next.js 16 (previous model) | |
|---|---|---|---|---|
fetch with no cache option | Cached indefinitely | Not cached | Not cached | |
GET Route Handlers | Cached unless they read the request | Not cached | Not cached | |
Caching a non-fetch function | unstable_cache | unstable_cache | unstable_cache |
In Next.js 15 and 16, nothing is cached unless the code asks. A codebase that feels slow after an upgrade from 14 probably relied on implicit caching and needs explicit cache: 'force-cache', a revalidate window, or 'use cache' where it used to get caching for free.
There is one subtlety that catches people in the other direction. A plain fetch with no options does not make a route dynamic. If nothing else in the route reads request-time data — cookies(), headers(), searchParams, draftMode() — Next.js prerenders the route at build time, runs the fetch once during next build, and bakes the result into the static HTML. The page then serves that baked-in data until the next build or a revalidation. So "not cached" does not mean "fetched on every visit" — it means the framework makes no promise, and the rendering mode decides what actually happens. If you want data that refreshes, say so explicitly, as section 4 shows.
4. Opting In: force-cache, no-store, and revalidate
In the previous caching model — the default in Next.js 16 — you name your intent per fetch call:
export default async function Page() {
// Not cached: hits the origin on every request.
const fresh = await fetch('https://api.example.com/cart');
// Cached until you invalidate it. The old getStaticProps behaviour.
const catalog = await fetch('https://api.example.com/products', {
cache: 'force-cache',
});
// Cached, then refreshed in the background at most once an hour.
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600, tags: ['blog-posts'] },
});
// Explicitly never cached. The old getServerSideProps behaviour.
const user = await fetch('https://api.example.com/me', {
cache: 'no-store',
});
}Decide per call, not per app:
cache: 'force-cache' — for data that rarely changes: product catalogs, public configuration, marketing content, documentation. The response is served from the Data Cache until you invalidate it.next: { revalidate: n } — for data that changes on a schedule: a blog index refreshed hourly, a pricing page refreshed daily. After n seconds the next request still gets the cached response while Next.js refreshes it in the background, so visitors never wait on the origin.cache: 'no-store' — for genuinely per-request data: the signed-in user's cart, a dashboard scoped to one account, anything behind authentication. Caching this is not an optimisation, it is a bug that shows one user another user's data.The tags in the revalidate example matter for section 5. A tag is a label you attach to cached data so you can invalidate exactly that data later, instead of everything.
5. Revalidation: Time-Based and On-Demand
Caching without a way to invalidate is how pages go stale. Next.js gives you two revalidation modes.
Time-based revalidation is the next: { revalidate: n } option from section 4, or the route-level equivalent:
// Revalidate this whole route at most once every 60 seconds.
export const revalidate = 60;After the window passes, the next visit still receives the cached page instantly while Next.js regenerates it in the background. This is incremental static regeneration, and it is the right default for content that changes on a schedule — a blog, a docs site, a storefront catalog.
On-demand revalidation invalidates cached data the moment the underlying data changes, usually from a webhook or a Server Action:
import { revalidateTag, revalidatePath } from 'next/cache';
// After a product is updated in the CMS, drop every cached
// entry tagged 'products' — and nothing else.
revalidateTag('products');
// Or drop one specific path.
revalidatePath('/products/headphones');The discipline is to tag at write time and invalidate at change time: next: { tags: ['products'] } on the fetch, revalidateTag('products') in the webhook handler that fires when a product changes. Tagging too broadly (one tag for everything) means every change invalidates every page. Tagging nothing means your only invalidation tool is waiting for a timer. Name tags after the data they describe — products, blog-posts, pricing — and keep the list short enough to reason about.
In the Cache Components model, updateTag plays the same role as revalidateTag, inside 'use cache' functions. The name changes; the discipline does not.
6. Non-fetch Data: Dedupe with React cache, Persist with unstable_cache
Not all data comes from fetch. Database queries, ORM calls, and SDK clients have no cache option to set, so they need two different tools.
React's cache() deduplicates within a single request. If a layout, a page, and a component all need the same user record, wrapping the lookup means the database is hit once per request, not three times:
import { cache } from 'react';
export const getUser = cache(async (id) => {
return db.user.findUnique({ where: { id } });
});This is per-request memoization, not persistence. The next request runs the query again. Use it to stop duplicate work inside one render, not as a caching strategy.
unstable_cache persists non-fetch work across requests in the previous model:
import { unstable_cache } from 'next/cache';
const getProducts = unstable_cache(
async () => db.product.findMany(),
['products'],
{ revalidate: 3600, tags: ['products'] }
);The arguments are the function, a key that identifies the cached result, and the same revalidate and tags options fetch takes. In the Cache Components model this is replaced by the 'use cache' directive, covered in section 9. Either way, the rule is the same: a database query with no caching wrapper runs on every request, exactly like an uncached fetch.
7. Parallel Fetching and the Preload Pattern
Server Components make one classic performance mistake easy: awaiting fetches one after another when they do not depend on each other.
// Slow: the second fetch waits for the first to finish.
const products = await fetch('https://api.example.com/products').then((r) => r.json());
const reviews = await fetch('https://api.example.com/reviews').then((r) => r.json());// Fast: both fetches start together.
const [products, reviews] = await Promise.all([
fetch('https://api.example.com/products').then((r) => r.json()),
fetch('https://api.example.com/reviews').then((r) => r.json()),
]);The same waterfall appears across components: a page awaits data, then renders a child that awaits more data. The fix is the preload pattern — start the fetch early without awaiting it, and await the promise where the data is needed:
import { preload } from './data';
export default async function Page({ params }) {
preload(params.id); // starts the fetch; does not block
const data = await getData(params.id); // awaits the already-running fetch
return <Product data={data} />;
}A useful way to check for waterfalls is to look at the network tab during a server render, or simply to read the component tree top-down and ask, for each await, whether the next line actually needs its result. If it does not, the two fetches belong in the same Promise.all.
8. Streaming with Suspense: Don't Block the Page on Slow Data
A page that awaits all of its data before rendering anything is slow in the way visitors notice most: a blank screen. Suspense lets the fast parts render immediately while slow parts stream in:
import { Suspense } from 'react';
export default function Page() {
return (
<main>
<ProductHeader />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</main>
);
}
async function Reviews() {
const reviews = await fetch('https://api.example.com/reviews', {
next: { revalidate: 3600 },
}).then((r) => r.json());
return <ReviewList reviews={reviews} />;
}The header renders instantly; the reviews section shows a skeleton, then streams in when its fetch resolves. The same idea applies at the route level with a loading.js file, which wraps the whole page in a Suspense boundary automatically. Use route-level loading.js for full-page loading states and component-level Suspense when only one section is slow.
Two cautions. First, a Suspense boundary around everything is the same as blocking — put boundaries around the slow units, not the page. Second, streaming does not make a slow fetch fast; it makes a slow fetch invisible while the rest of the page is already useful. If the fetch itself is the problem, fix the fetch — cache it, parallelize it, or move it closer to the data.
9. Cache Components in Next.js 16: The Opt-In Model
Next.js 16 adds a second caching model, Cache Components, enabled with cacheComponents: true in your Next config. It is opt-in, and the release notes say its behaviour will become the default in a future major version — so it is worth understanding now, even if your app does not use it yet.
With the flag on, caching is explicit. You mark cacheable code with the 'use cache' directive, control its lifetime with cacheLife, and tag it with cacheTag:
import { cacheLife, cacheTag } from 'next/cache';
export async function getProducts() {
'use cache';
cacheLife('hours');
cacheTag('products');
return db.product.findMany();
}cacheLife accepts a preset profile — seconds, minutes, hours, days, weeks, or max — or an object with stale, revalidate, and expire values in seconds. Pages are partially prerendered by default: the static shell renders immediately and dynamic holes stream in, which is the Suspense pattern from section 8 applied by the framework.
Most existing apps, including this portfolio, use the previous model, and that model is still fully supported. The practical guidance is simple: do not mix models in one route, do not enable Cache Components on an app whose caching you have not audited, and when you do adopt it, migrate one route at a time — starting with the read-heavy, rarely changing pages where explicit caching pays off most. Whichever model you use, the rule from section 3 still holds: nothing is cached unless the code asks.
10. Static vs. Dynamic Rendering, and Segment Config
In the previous model, Next.js decides per route whether it can be prerendered at build time (static) or must render per request (dynamic). A route becomes dynamic when it uses request-time APIs — cookies(), headers(), searchParams, draftMode(), or connection() — or a fetch with cache: 'no-store'. Everything else prerenders.
You can override that decision per route with route segment config exports:
// Revalidate this route at most once every 60 seconds (ISR).
export const revalidate = 60;
// Force this route to render on every request.
export const dynamic = 'force-dynamic';
// Force this route to be static, even if it reads request data.
export const dynamic = 'force-static';force-dynamic is the escape hatch for pages that must never be cached — a checkout page, an account page. force-static is the opposite, and it should be used carefully: forcing a route static when it reads per-user data serves one user's page to everyone. Before reaching for either, check whether the real problem is a missing revalidate window or an uncached fetch — segment config is a blunt tool, and the per-fetch options in section 4 are usually the precise one.
One more check that saves real debugging time: verify caching with next build followed by next start, not with next dev. The development server does not apply the production caching rules, so a page that looks fresh in development and stale in production is usually working as designed — the design just was not visible until the production build.
11. Checklist
cacheComponents: true).fetch: force-cache, revalidate with tags, or no-store — never leave it implicit.next: { tags }) and invalidate at change time (revalidateTag / revalidatePath).cache(); persist non-fetch work with unstable_cache or 'use cache'.Promise.all; preload to avoid component-tree waterfalls.Suspense so the page renders before its slowest data arrives.next build + next start, not next dev.fetch after an upgrade from Next.js 14 — the default flipped, and the compiler will not tell you.Conclusion
Next.js data fetching is not complicated once the default is clear: in versions 15 and 16, nothing is cached unless the code asks. That one sentence explains the stale pages after an upgrade, the slow pages that fetch on every request, and the tutorials that contradict the behaviour you see.
The working method follows from it. Fetch on the server, close to the data. Name the intent of every call — force-cache for stable data, revalidate with tags for scheduled data, no-store for per-user data. Invalidate by tag the moment data changes instead of waiting for a timer. Dedupe within a request, parallelize independent fetches, and stream the slow parts behind Suspense so visitors see the page before its slowest data arrives. Do that, and caching stops being a source of mysterious staleness and becomes what it should be: a deliberate decision, visible in the code, per fetch.
Next Recommended Reads

Yash Nandvana• Full Stack Developer
Full Stack & Shopify Developer building scalable web apps, developer tools, and AI solutions.
Learn more about YashRelated Articles
PostgreSQL + pgvector: Building Semantic Search and RAG Without a Separate Vector Database
A complete, practical tutorial for adding semantic search, hybrid search, and RAG to an existing PostgreSQL application using pgvector — covering embeddings, HNSW, metadata filtering, Reciprocal Rank Fusion, and background embedding pipelines.
React Performance Optimization: A Practical Guide to Faster Rendering
A practical guide to React performance: memo, useMemo, useCallback, state placement, list keys, virtualization, lazy loading, and context — plus when not to optimize at all.
Shopify Webhooks: A Complete Guide for Developers
A comprehensive, production-tested guide to Shopify webhooks: HMAC signature verification, raw body handling, idempotency with Redis, queue architecture, and local testing.
