Data Fetching and Caching in Next.js (App Router)
1. Overview
Next.js App Router automatically caches the results of fetch on the server to reduce backend load and speed up pages. However, this caching mechanism is quite different from the standard HTTP caching you’re familiar with, and if you don’t understand it well, you’ll easily run into situations where “old data refuses to update” or conversely “every page rerenders from scratch, unbearably slow.”
This guide covers the cache layers of Next.js: the fetch cache, time-based revalidation, tag-based revalidation, and dynamic rendering. After reading, you’ll know how to choose the right fetch strategy for each type of page (static, ISR, dynamic) and debug when cache acts “stubborn.”
Note: this article applies to App Router (Next.js 13+, version 14/15 recommended). Pages Router uses a different mechanism (
getStaticProps,getServerSideProps) and is outside the scope of this guide.
2. Four Cache Layers of Next.js
Next.js has 4 distinct caching mechanisms that can easily be confused if lumped together:
| Mechanism | Location | Purpose |
|---|---|---|
| Request Memoization | Server, within one render | Deduplicate identical fetch calls in the same request |
| Data Cache | Server, across requests | Cache fetch results long-term, can be revalidated |
| Full Route Cache | Server, build time | Cache HTML + RSC payload of static routes |
| Router Cache | Client, within session | Cache navigation results for fast back/forward |
This article focuses on Data Cache because that’s what you control directly via fetch options.
3. Default fetch: “indefinite” cache
Inside a Server Component, fetch is patched by Next.js to cache by default:
// app/products/page.tsx
async function getProducts() {
const res = await fetch("https://api.shop.example.com/products");
if (!res.ok) throw new Error("Failed to fetch products API");
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p: { id: string; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
fetchwithout passingcache→ Next.js defaults toforce-cache.- The result is stored in the Data Cache, shared across all subsequent requests, never expires unless you revalidate it.
- This page enters the Full Route Cache: built into static HTML, served like a static page.
This is why many people are surprised: they change data in the backend but the page doesn’t update — because Next.js treats this as static data.
4. Controlling Cache with the cache Option
To always fetch fresh data, use no-store:
async function getOrderStatus(orderId: string) {
const res = await fetch(
`https://api.shop.example.com/orders/${orderId}`,
{ cache: "no-store" }
);
return res.json();
}
cache: "no-store"completely disables Data Cache for this request.- The route containing this fetch will be marked as dynamic, rerendering on each request.
- Good for data that changes frequently: processing orders, real-time prices, dashboards.
Note: just one
fetchusingno-storein a page makes the entire page dynamic — even otherfetchcalls in the same page still useforce-cache.
5. Revalidate by Time (ISR)
To cache but auto-refresh after N seconds (Incremental Static Regeneration), use next.revalidate:
async function getCategories() {
const res = await fetch(
"https://api.shop.example.com/categories",
{ next: { revalidate: 300 } } // 5 minutes
);
return res.json();
}
revalidate: 300means: data is considered “stale” after 300 seconds.- The first request after the 300s mark still returns old data (stale-while-revalidate), but Next.js triggers a fresh fetch in the background.
- The next request receives the new data.
You can apply this to the entire route by exporting a variable at the top of the file:
// app/blog/[slug]/page.tsx
export const revalidate = 300;
export default async function BlogPost({ params }: { params: { slug: string } }) {
const res = await fetch(`https://api.blog.example.com/posts/${params.slug}`);
return <article>{/* ... */}</article>;
}
export const revalidateapplies to all fetches in the route if that fetch doesn’t declare its ownrevalidate.- If a specific fetch declares a different
next.revalidate, that value wins for that fetch.
6. Revalidate by Tag: revalidateTag
Time-based revalidation works well for content that changes at regular intervals, but it doesn’t fit when you want to refresh immediately when data changes (e.g., admin just updated a product price). That’s when you use cache tags.
// app/products/[id]/page.tsx
async function getProduct(id: string) {
const res = await fetch(
`https://api.shop.example.com/products/${id}`,
{ next: { tags: [`product-${id}`] } }
);
return res.json();
}
When admin updates a product, call revalidateTag in a Route Handler or Server Action:
// app/actions/update-product.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updateProduct(id: string, formData: FormData) {
await fetch(`https://api.shop.example.com/products/${id}`, {
method: "PATCH",
body: formData,
});
revalidateTag(`product-${id}`); // clear cache with this tag
}
tagsattach a “label” to the fetch’s cache entry.revalidateTag(tag)clears all Data Cache entries with that tag, regardless of which route uses it.- The next access will fetch fresh data and cache it with the new tag.
This is the most powerful mechanism because it decouples “when to invalidate” from “which page displays the data” — similar to cache tagging in Spring Cache or Redis.
7. Revalidate by Path: revalidatePath
If you don’t use tags, you can revalidate directly by route path:
"use server";
import { revalidatePath } from "next/cache";
export async function publishPost(slug: string) {
// ... call publish API ...
revalidatePath(`/blog/${slug}`); // just this page
revalidatePath("/blog", "page"); // or the whole list
}
revalidatePath("/blog/[slug]")clears the Full Route Cache + Data Cache of that exact route.- The second parameter (
"page"or"layout") determines whether to also revalidate child routes using that layout. - Different from
revalidateTag:revalidatePathis tied to a specific route, while tags can be used across multiple routes.
Note:
revalidateTag/revalidatePathonly work inside Server Actions or Route Handlers; you can’t call them directly from Client Components.
8. Dynamic Rendering: When Caching No Longer Makes Sense
Some APIs cause Next.js to automatically switch a route to dynamic, bypassing Full Route Cache:
import { cookies, headers } from "next/headers";
export default async function AccountPage() {
const sessionToken = (await cookies()).get("session")?.value;
const userAgent = (await headers()).get("user-agent");
const res = await fetch("https://api.shop.example.com/me", {
headers: { Authorization: `Bearer ${sessionToken}` },
cache: "no-store",
});
return <div>Hello, {(await res.json()).name}</div>;
}
- Calling
cookies()orheaders()automatically marks the route as dynamic — it makes sense because data depends on the specific user. - Combine with
cache: "no-store"to ensure data is always fresh per session.
Explicitly force dynamic rendering (e.g., when you know you can’t cache):
export const dynamic = "force-dynamic";
9. Route Handlers Also Have Their Own Cache
Route Handlers (app/api/.../route.ts) with GET method also follow the same caching rules as Server Components:
// app/api/stock/route.ts
export const revalidate = 60;
export async function GET() {
const res = await fetch("https://api.shop.example.com/stock");
const data = await res.json();
return Response.json(data);
}
GETthat doesn’t use theRequestobject (doesn’t readrequest.urlfor query params) may be cached statically.- If the handler uses
request.headers,request.cookies, or methods other thanGET(POST,PUT, etc.) → always dynamic, no caching.
10. Client Components: Caching Belongs to a Different Library
Next.js’s fetch cache only applies on the server. In Client Components, you fetch yourself with useEffect or use a separate caching library like SWR:
npm install swr
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function CartBadge({ userId }: { userId: string }) {
const { data, isLoading } = useSWR(`/api/cart/${userId}`, fetcher, {
refreshInterval: 10_000, // poll every 10s
});
if (isLoading) return <span>...</span>;
return <span>{data.itemCount} items</span>;
}
- SWR has its own cache, deduplicates requests, revalidates on focus — completely independent of the server’s Data Cache.
- Use it when you need frequently updating data on the client without reloading the entire page.
11. Writing Tests for Cache Key Logic
Next.js’s caching mechanism itself is hard to unit test directly, but the logic for creating tags/paths is often worth testing separately — avoid typos in tags that cause revalidateTag mismatches.
// lib/cache-keys.ts
export function productTag(id: string): string {
return `product-${id}`;
}
export function categoryTag(id: string): string {
return `category-${id}`;
}
// lib/cache-keys.test.ts
import { describe, it, expect } from "vitest";
import { productTag, categoryTag } from "./cache-keys";
describe("cache-keys", () => {
it("generates product tag in correct format", () => {
expect(productTag("sku-123")).toBe("product-sku-123");
});
it("product and category tags don't collide", () => {
const id = "42";
expect(productTag(id)).not.toBe(categoryTag(id));
});
});
- Extracting tag generation into a separate module ensures that everywhere you call
fetch(to attach tags) andrevalidateTag(to clear tags) always use the same format. - This is a real mistake people make often: one place writes
product-${id}, another writesproducts-${id}→ revalidation never matches.
✓ cache-keys > generates product tag in correct format
✓ cache-keys > product and category tags don't collide
12. Common Mistakes
- Forgetting that fetch caches indefinitely by default: deploy and data doesn’t change because you’re still using old cache from the previous build (with static export) or from the first fetch (with server deploy). Fix with
revalidateorno-store. - Mixing
no-storeand cache in the same component unnecessarily: just oneno-storefetch makes the entire page dynamic, losing the cache benefits of other fetches. - Calling
revalidateTag/revalidatePathoutside Server Action/Route Handler: has no effect, Next.js will error or silently ignore it depending on version. - Using regular
fetch(not patched by Next.js) inside third-party libraries (e.g., SDKs usingaxiosor vanillaundici): not managed by Data Cache, you must revalidate another way (e.g., cache at a different layer or write a wrapper calling standardfetch). - Not tagging by specific entity: using overly broad tags (
"products"for all products) means one small update invalidates the entire cache, losing the benefit of per-item caching.
When NOT to cache: personalized data per session (shopping cart, balance, notifications), real-time data (stock prices, payment status), or any response depending on
cookies()/headers(). These cases should useno-storeorforce-dynamicfrom the start — don’t try to force cache then revalidate constantly — it’s both complex and wrong for the data’s nature.
13. Conclusion
Caching in Next.js App Router has many layers, but the part you control most directly is Data Cache through fetch options: cache, next.revalidate, next.tags. Quick selection rule: data changes rarely → revalidate by time; data changes on specific events (admin update) → attach tags and use revalidateTag; data is per-user or real-time → no-store/force-dynamic. Master these three groups, and you’ll rarely have to guess “why won’t this data update?” again.