The Next.js App Router fundamentally restructured data fetching in React applications by shifting execution to React Server Components and streaming UI at component boundaries.
This post is the map I use on real projects: Server Components first, explicit cache policy, time or tag revalidation, and a short list of cases where client fetch is still the right call.
Server Components are the default data path
In the App Router, page.tsx and most components are Server Components unless you mark them 'use client'. That means you can make them async, call fetch, talk to a database, and keep secrets on the server.
// app/blog/page.tsx
export default async function BlogPage() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return (
<ul>
{posts.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
What you gain:
- No API round-trip for first paint for that data (the server already has it when HTML/RSC streams).
- Secrets stay server-side (DB URLs, private tokens).
- Less client JS for pure display trees.
You do not need a Route Handler just to load data for a page. Route Handlers are for HTTP APIs other clients call, webhooks, or browser fetch targets. Prefer loading data in the Server Component that renders it.
Identical GET fetch calls with the same URL and options are memoized for one render pass. Layout and page can both call getUser() without doubling the network hit in that request.
Cache defaults changed. Set them on purpose.
In Next.js 14, many fetch calls were cached by default (force-cache style). In Next.js 15+, the default is closer to do not cache (auto / uncached at request time for dynamic routes). If you upgraded and "static" pages started hitting your API every request, this is why.
Pick a policy per request:
| Intent | Option |
|---|---|
| Always fresh | cache: 'no-store' or next: { revalidate: 0 } |
| Cache until you invalidate | cache: 'force-cache' (optionally with tags) |
| Cache for N seconds (ISR-style) | next: { revalidate: 3600 } |
| Cache + invalidate by name | next: { tags: ['posts'] } with revalidateTag |
// Always hit the origin (user dashboard, live price, etc.)
const live = await fetch(url, { cache: "no-store" });
// Cache for an hour (marketing catalog, docs index)
const catalog = await fetch(url, {
next: { revalidate: 3600 },
});
// Cache hard until a tag or path is revalidated
const product = await fetch(url, {
cache: "force-cache",
next: { tags: ["product", `product-${id}`] },
});
Do not mix conflicting knobs. { cache: 'no-store', next: { revalidate: 3600 } } is invalid and Next will ignore the pair (and warn in dev).
Route segment config (when the whole page should behave one way)
// Force dynamic rendering for this segment
export const dynamic = "force-dynamic";
// Or set a default revalidation window for the segment
export const revalidate = 300;
Use segment config sparingly. Prefer per-fetch options so one slow live widget does not force the whole marketing page dynamic. When a layout reads cookies() or headers(), that segment (and often children) becomes dynamic whether you wanted it or not. Keep auth-aware data in small leaf components, not in the root layout.
Time-based revalidate vs on-demand tags
Time-based (revalidate: N)
Good when data can be slightly stale and you do not control every write path: public blog list, docs, product grid that updates a few times an hour.
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
After 60 seconds, the next request can trigger a revalidation (stale-while-revalidate style depending on deployment). Readers still get a fast response while the cache refreshes in the background.
On-demand (tags + revalidateTag / revalidatePath)
Good when a known write should clear the cache: CMS publish, admin edit, form submit via Server Action.
// app/lib/posts.ts
export async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"], revalidate: 3600 },
});
return res.json();
}
// app/actions.ts
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function publishPost(formData: FormData) {
await savePost(formData); // your DB/API write
revalidateTag("posts");
revalidatePath("/blog");
}
revalidateTag: surgical. Tagproduct-42and leave the rest of the catalog alone.revalidatePath: coarser. Clears cached data associated with a path. Useful when you think in routes, not data domains.
Tag naming that scales: plural resource tags (posts, inventory) plus optional instance tags (post-${id}). Invalidate both on write when the detail and the list both need to change.
Database and ORM: unstable_cache and React.cache
fetch caching only covers fetch. Prisma, Drizzle, and raw SQL need something else.
Deduplicate within one request: React.cache
import { cache } from "react";
import { db } from "@/lib/db";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
Same request, many components: one query. This is not a cross-request cache. Next render pass starts clean.
Cross-request cache: unstable_cache
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
export const getCachedPosts = unstable_cache(
async () => {
return db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20 });
},
["posts-list"],
{ revalidate: 120, tags: ["posts"] }
);
Use it for expensive reads that many users share. Still call revalidateTag("posts") after mutations. The name is awkward; the API is what most production apps used through 2025 for non-fetch caching. Treat the key array as a stable identity for that function shape.
Parallel vs sequential fetches
Sequential by accident is the usual footgun:
// Bad: second waits on first even if independent
const user = await getUser(id);
const feed = await getFeed(id);
Start both, then await:
const userPromise = getUser(id);
const feedPromise = getFeed(id);
const [user, feed] = await Promise.all([userPromise, feedPromise]);
When B needs A's id, keep sequential for that edge, but stream the rest with Suspense so the page shell does not wait on the slow branch.
import { Suspense } from "react";
export default async function Page({
params,
}: {
params: Promise<{ username: string }>;
}) {
const { username } = await params;
const artist = await getArtist(username);
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<p>Loading playlists...</p>}>
<Playlists artistId={artist.id} />
</Suspense>
</>
);
}
async function Playlists({ artistId }: { artistId: string }) {
const playlists = await getPlaylists(artistId);
return (
<ul>
{playlists.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
loading.tsx in a segment wraps the page in Suspense for free. Prefer tighter boundaries around the slow parts so static chrome still paints early.
When client fetch still makes sense
Server Components are default. Client fetch is not banned. Use it when the browser must own the request lifecycle.
| Use client fetch when... | Prefer server when... |
|---|---|
| Data depends on user events after mount (search-as-you-type, infinite scroll) | First paint needs the data |
| Polling or WebSocket-driven UI | Secrets or private APIs |
| Browser-only APIs (geolocation, local tokens you refuse to send server-side) | SEO and crawler-visible content |
| Highly interactive caches (SWR / React Query for optimistic UX) | Shared public data with a known revalidate window |
Pattern: server seeds, client keeps it fresh
// Server page
import { ProductClient } from "./product-client";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id); // server, maybe force-cache + tags
return <ProductClient initial={product} id={id} />;
}
// product-client.tsx
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function ProductClient({
initial,
id,
}: {
initial: Product;
id: string;
}) {
const { data } = useSWR(`/api/products/${id}`, fetcher, {
fallbackData: initial,
revalidateOnFocus: true,
});
return <h1>{data.title}</h1>;
}
The Route Handler or public API behind /api/products/... should still enforce auth. Passing a Promise from the server into a client component and resolving it with React's use() is another clean streaming option when you do not need client revalidation later.
What not to do
- Mark the whole page
'use client'and re-fetch the homepage from the browser "because that is how SPA worked." - Put DB credentials in a client bundle via a "temporary" env leak.
- Call
revalidateTagfrom the client. That belongs in Server Actions or Route Handlers.
A practical decision tree
- Does first HTML need this data? Fetch in a Server Component.
- Is it user-specific or secret? Server only. Read cookies/session on the server.
- Can it be shared across users for N seconds?
revalidate: Norforce-cache+ tags. - Does a known mutation make it stale? Tag it and call
revalidateTagin the Server Action. - Is it not
fetch?React.cachefor request dedupe;unstable_cachefor cross-request cache. - Is the next update driven by the user's browser after load? Client fetch / SWR / React Query, optionally seeded from the server.
- Is one branch slow? Split with Suspense /
loading.tsxinstead of blocking the whole route.
Common mistakes I still see
- Assuming Next 14 cache defaults after an upgrade. Explicit
cache/revalidatein shared data helpers saves hours of "why is this always dynamic" debugging. - Putting
cookies()in the root layout and wondering why every child is dynamic. - Caching personalized responses under a shared key (one user sees another user's cart). Personal data: no cross-user cache, or key by session id carefully.
- Forgetting to revalidate after writes. ISR alone is not enough if editors expect instant publish.
- Sequential awaits for independent sources (layout waterfall).
- Using client fetch for SEO-critical content that should have been on the server.
Minimal checklist before you ship a data-heavy route
- Each
fetchhas an intentional cache policy. - Mutations call
revalidateTag/revalidatePathfor the tags/paths they affect. - DB helpers use
React.cachewhere multiple components need the same row in one request. - Expensive public queries use
unstable_cache(or equivalent) with tags. - Slow sections are behind Suspense with a real skeleton, not a blank page.
- Client components own interaction and live refresh, not the first load of public content.
- No secrets in client bundles; auth checks on every Route Handler the browser can hit.
App Router data fetching rewards boring choices: load on the server, say how long the result can live, invalidate when you write, and only open a client request when the browser actually needs to drive the timeline. Once that split is habit, the cache options stop feeling magical and start feeling like normal infrastructure knobs.
