Next.js App Router data fetching patterns: fetch, cache, and revalidate
Why data fetching changed so much
In the Pages Router era, data fetching happened in exactly three places: getStaticProps, getServerSideProps, or inside client-side useEffect calls. Each was a clear contract with a predictable behaviour. The App Router broke that mould — there is no longer a single file-level function that owns data for a route; instead, any Server Component can fetch or query a database directly, and Next.js layers its own caching on top of the native web fetch API.
The power is real. You can co-locate data fetching with the component that needs it, run multiple independent fetches in parallel without any extra wiring, stream slow components while fast ones render immediately, and query your database directly without building an internal API just so your own frontend can talk to your own backend.
The confusion is also real. Questions like "why is my data stale after I deployed?" or "why is this page always slow even though I'm caching?" or "why does my Server Component run multiple times?" almost always trace back to a misunderstood caching layer. This guide builds a complete mental model of all four caches and maps the five primary data fetching patterns to the right approach.
The four caching layers you must understand
Understanding the App Router means understanding four distinct caches that sit between your code and the user. They operate independently, have different lifetimes, and are invalidated by different mechanisms. Mixing them up is the root cause of most data-freshness bugs.
| Layer | What it caches | Lifetime | How to invalidate |
|---|---|---|---|
| Request Memoisation | Duplicate fetch() calls with identical URL + options within one render pass | Single request | Automatic at request end |
| Data Cache | fetch() responses, stored on disk, shared across requests | Indefinite | revalidate option or revalidatePath() / revalidateTag() |
| Full Route Cache | Rendered HTML + RSC payload for static routes, stored on disk | Indefinite | Redeploy or revalidatePath() |
| Router Cache | RSC payload cached in the browser per-navigation | 30s (dynamic) / 5min (static) | router.refresh() or a new navigation |
The interaction between these layers is what makes behaviour feel magical or surprising. A fetch() that hits the Data Cache never reaches your origin server — but that also means a code change to how you transform the response has no visible effect until the Data Cache is invalidated. A route that looks dynamic in development may actually be statically cached in production if Next.js decides it qualifies. Knowing which layer controls the data you are looking at is the key diagnostic skill.
Pattern 1 — Static data (cached indefinitely)
The default behaviour of fetch() in a Server Component is to cache the response indefinitely. This is equivalent to getStaticProps in the Pages Router: the data is fetched at build time (or first request), stored in the Data Cache, and reused for every subsequent visitor without hitting the origin.
// app/products/page.tsx
async function getProducts() {
const res = await fetch("https://api.example.com/products");
// No cache option specified = cache: "force-cache" (the default)
if (!res.ok) throw new Error("Failed to fetch products");
return res.json() as Promise<Product[]>;
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
</main>
);
}
This page is statically rendered. The first time anyone visits it, the fetch runs, the response is stored in the Data Cache and the Full Route Cache, and every subsequent visitor gets the pre-rendered HTML from disk. Your origin server never receives another request for this page until the cache is explicitly invalidated.
When to use: Marketing pages, documentation, product listings where prices and stock don't change second-to-second, anything where a few minutes or hours of staleness is acceptable. Static rendering is dramatically faster than dynamic — it eliminates both the server execution time and the origin round-trip.
Pattern 2 — Time-based revalidation (ISR)
The next.revalidate option tells Next.js to treat the cached response as stale after N seconds. The first request after expiry triggers a background re-fetch while still serving the stale response immediately (stale-while-revalidate semantics), and subsequent requests get the fresh data.
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 }, // treat as stale after 60 seconds
});
return res.json() as Promise<Post[]>;
}
You can also set a route-level revalidation period with a segment config export, which applies to every fetch in that route segment that does not override it explicitly:
// app/blog/page.tsx
export const revalidate = 300; // 5 minutes, applies to all fetches in this route
export default async function BlogPage() {
// Inherits revalidate = 300 from the segment config
const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
const featured = await fetch("https://api.example.com/featured").then((r) => r.json());
// Both fetches revalidate after 5 minutes
return (
<>
<FeaturedPost post={featured} />
<PostList posts={posts} />
</>
);
}
If different parts of a page need different revalidation periods, set revalidate per fetch rather than at the route level:
export default async function NewsPage() {
const [breaking, analysis] = await Promise.all([
fetch("https://api.example.com/breaking", { next: { revalidate: 30 } }).then(r => r.json()),
fetch("https://api.example.com/analysis", { next: { revalidate: 3600 } }).then(r => r.json()),
]);
return (
<>
<BreakingNewsTicker items={breaking} />
<AnalysisSection articles={analysis} />
</>
);
}
Pattern 3 — Dynamic data (no caching)
Opt out of caching with cache: "no-store" to get fresh data on every single request. This is equivalent to getServerSideProps — the fetch runs on every visit, the response is never stored in the Data Cache, and the route is always server-rendered at request time.
async function getDashboardData(userId: string) {
const res = await fetch(`https://api.example.com/dashboard/${userId}`, {
cache: "no-store",
});
if (!res.ok) throw new Error("Failed to load dashboard");
return res.json();
}
export default async function DashboardPage() {
const session = await getSession(); // reading cookies makes the route dynamic anyway
const data = await getDashboardData(session.userId);
return <Dashboard data={data} user={session.user} />;
}
A route also becomes automatically dynamic — without any explicit cache: "no-store" — when it reads cookies(), headers(), or searchParams. Next.js detects these at build time and marks the route as dynamic. You can also force it explicitly:
export const dynamic = "force-dynamic";
Avoid unnecessary dynamic routes. A very common mistake is wrapping every fetch in cache: "no-store" as a defensive measure, then wondering why the app is slow. Dynamic rendering is meaningfully more expensive: it requires a server execution on every request, potentially a database query or external API call, and it cannot be served from the Full Route Cache. Reserve it for data that genuinely must be fresh for each individual visitor — personalised dashboards, shopping carts, live inventory counts.
Pattern 4 — On-demand revalidation
Time-based TTLs work for polling patterns, but not for event-driven content. When a CMS editor publishes an article, you want the cache invalidated in seconds, not after a 5-minute TTL expires. revalidatePath and revalidateTag solve this.
Tagged caching lets you associate fetch calls with semantic labels and invalidate all of them at once:
// Tag the fetch at ingestion time
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
return res.json();
}
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ["posts", `post-${id}`] },
});
return res.json();
}
Create a webhook endpoint that your CMS calls on publish:
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { tag, path, postId } = body;
if (postId) {
// Invalidate this specific post and the posts listing
revalidateTag(`post-${postId}`);
revalidateTag("posts");
} else if (tag) {
revalidateTag(tag);
} else if (path) {
revalidatePath(path);
}
return Response.json({ revalidated: true, timestamp: Date.now() });
}
The power of tags is that a single revalidateTag("posts") purges the cached response for every fetch tagged "posts" across every route in your application — the blog listing page, the sitemap route, the RSS feed endpoint, and the related posts widget on individual post pages — all in one call.
You can also call revalidatePath and revalidateTag from Server Actions, which is useful for self-contained mutations in the same app:
// app/admin/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
export async function publishPost(formData: FormData) {
const id = formData.get("id") as string;
await fetch(`https://api.example.com/posts/${id}/publish`, {
method: "POST",
cache: "no-store",
});
revalidateTag("posts");
revalidateTag(`post-${id}`);
redirect(`/blog/${id}`);
}
Pattern 5 — Streaming with Suspense
When a page contains multiple independent data sources — some fast and some slow — <Suspense> boundaries let you stream the fast parts to the browser immediately while the slow parts continue loading in the background. The user sees content appear progressively rather than waiting for the slowest fetch.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RevenueChart } from "./RevenueChart";
import { ActivityFeed } from "./ActivityFeed";
import { RecentOrders } from "./RecentOrders";
import { ChartSkeleton, FeedSkeleton, OrdersSkeleton } from "./Skeletons";
export default function DashboardPage() {
// Note: this is NOT an async component — it just composes Suspense boundaries.
// Each child component does its own async work.
return (
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
<div>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
<div className="col-span-3">
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</div>
);
}
// app/dashboard/RevenueChart.tsx (async Server Component)
export async function RevenueChart() {
// This fetch runs in parallel with ActivityFeed and RecentOrders
const data = await fetch("https://api.example.com/revenue/monthly", {
cache: "no-store",
}).then((r) => r.json());
return <BarChart data={data} />;
}
The key insight: all three child components run their fetches in parallel. Without Suspense, you would have to either await them serially (slow) or manually reach for Promise.all at a higher level (awkward). With Suspense, each boundary streams independently the moment its data resolves — no coordination code required.
Nest Suspense boundaries for progressive disclosure. A common pattern is an outer boundary that shows a page skeleton while the primary content loads, and inner boundaries for secondary content like sidebars and related items that can load after the main content is visible.
Pattern 6 — Direct database queries in Server Components
Server Components run exclusively on the server, which means you can query your database directly without building an intermediate API layer. There is no security risk in doing this — the database credentials never leave the server, and no database code is ever sent to the browser.
// app/users/[id]/page.tsx
import { db } from "@/lib/db"; // Drizzle, Prisma, Kysley, or any Node.js DB client
import { notFound } from "next/navigation";
type Props = {
params: Promise<{ id: string }>; // Promise in Next.js 15+
};
export default async function UserPage({ params }: Props) {
const { id } = await params;
const user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, id),
with: {
posts: { orderBy: (posts, { desc }) => [desc(posts.createdAt)], limit: 10 },
_count: { select: { followers: true } },
},
});
if (!user) notFound();
return (
<div>
<h1>{user.name}</h1>
<p>{user._count.followers} followers</p>
<PostList posts={user.posts} />
</div>
);
}
This eliminates a full network round-trip and removes the need to design, implement, and maintain an internal /api/users/[id] endpoint. The component co-locates its data requirements with its rendering logic — the same principle as React Query colocation, but without any client-side state.
The tradeoff: direct database queries are not automatically cached by Next.js's Data Cache (which only wraps fetch). For queries that can tolerate staleness, use unstable_cache:
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
// Cache this query for 60 seconds and tag it for on-demand invalidation
const getCachedUser = unstable_cache(
async (id: string) =>
db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, id),
}),
["user"], // cache key prefix
{ revalidate: 60, tags: ["users"] }
);
export default async function UserPage({ params }: Props) {
const { id } = await params;
const user = await getCachedUser(id);
if (!user) notFound();
return <UserProfile user={user} />;
}
unstable_cache brings the same TTL and tag-based invalidation semantics to ORM queries, Redis reads, and any other non-fetch data source. The unstable_ prefix signals the API may change in future versions, but it is widely used in production Next.js apps.
Common mistakes and how to avoid them
Forgetting cache: "no-store" on personalised fetches. If a fetch includes user-specific data (a user ID, an auth token, personalised content) but does not opt out of caching, the first user's response is stored in the Data Cache and served to every subsequent visitor. Always audit fetches that include any user-specific parameter and ensure they carry cache: "no-store".
Accidentally opting the entire route into dynamic rendering. Reading cookies() or headers() anywhere in a Server Component — even deep in a utility function — makes the entire route dynamic. Extract session data at the layout level and pass it as a prop, or use a cached wrapper around the expensive work to avoid the route being fully dynamic when it does not need to be.
Awaiting params synchronously in Next.js 15. In Next.js 15, params and searchParams are Promises. Reading them synchronously produces a runtime error:
// ❌ Throws a runtime error in Next.js 15
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
}
// ✅ Correct: await params first
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
}
Using revalidatePath("/") to invalidate everything. This busts the cache for the home page only — it has no effect on any other route. For broad invalidation, use a tag that is shared across all relevant routes, or call revalidatePath with the "layout" type argument which cascades through all child segments:
revalidatePath("/blog", "layout"); // invalidates /blog, /blog/[id], /blog/tag/[slug], etc.
Fetching data sequentially when it could be parallel. Each await inside a Server Component blocks until that promise resolves before the next line runs:
// ❌ Sequential — total time = A + B + C
const user = await fetchUser(id);
const posts = await fetchPosts(id);
const related = await fetchRelated(id);
// ✅ Parallel — total time = max(A, B, C)
const [user, posts, related] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchRelated(id),
]);
When fetches are independent, always reach for Promise.all. When they are dependent (you need the result of one to construct the next request), sequential is correct — but try to minimise the number of dependent chains.
Not understanding the Router Cache on the client. Even after you call revalidatePath() on the server, a user who has already visited the page may see stale content because their browser's Router Cache still holds the previous RSC payload. After a mutation, call router.refresh() to force the client to discard its cached payload and re-fetch from the server:
"use client";
import { useRouter } from "next/navigation";
export function DeleteButton({ postId }: { postId: string }) {
const router = useRouter();
async function handleDelete() {
await fetch(`/api/posts/${postId}`, { method: "DELETE" });
router.refresh(); // discard Router Cache, re-fetch current route from server
}
return <button onClick={handleDelete}>Delete</button>;
}
Choosing the right pattern
Most real applications use every pattern simultaneously — on different routes, different components within a route, or different fetches within a component. A useful decision tree:
- Does this data change at all in production? No → static (default fetch). Yes → continue.
- Must the data be fresh for each individual user, or is shared freshness acceptable? Individual →
cache: "no-store". Shared → continue. - Can you predict approximately when the data changes? Yes → time-based
revalidate. No → on-demand revalidation via webhook +revalidateTag. - Is this component slow and independent of the other content on the page? Yes → wrap in
<Suspense>to stream it. - Is the data source a database you control? Yes → query directly in the Server Component; use
unstable_cacheif you need caching.
A product page, for example, might statically cache the product title and description (changes rarely, revalidate: 3600), dynamically fetch the current user's saved/wishlist status (cache: "no-store"), use on-demand revalidation for the inventory count (updated by a webhook from the warehouse system), and stream the "you might also like" section since it involves a slow ML recommendation API.
Closing thoughts
The App Router's data fetching model is more capable than the Pages Router's because it is not one model — it is five composable patterns that can be mixed within a single route, even within a single component tree. The investment in understanding the four caching layers pays back every time you debug a stale-data issue in under a minute instead of an hour, and every time you choose the right pattern on the first attempt instead of discovering the wrong one in production.
The mental model to carry: static by default, dynamic by exception, streaming for independence, on-demand for event-driven content. Start static, add dynamism only where the data genuinely requires it, and measure — the performance difference between a statically cached route and a fully dynamic one is often larger than teams expect.