In the previous module, we learned the basics of data revalidation. Now it's time to dive deep into the advanced caching mechanisms that the Next.js App Router offers under the hood. Understanding these layers is the key to building applications that run lightning-fast — like the systems in the cyberpunk city of Quantum, where every millisecond of delay can cost lives.
The Next.js App Router implements a multi-layered caching system. Each layer has a different purpose and different lifetime:
fetch is called multiple times within a single render (e.g., in different components), Next.js will execute it only once.fetch. It survives between user requests and even between deployments. This is the cache you control through revalidate and cache: 'no-store'.When a user visits a page, Next.js checks the cache in the following order:
1Router Cache (browser)
2 ↓ miss
3Full Route Cache (server)
4 ↓ miss
5Page rendering → Data Cache → Request Memoization → fetch to APIUnderstanding this flow allows you to precisely control which data is fresh and which can be served from cache.
Not all data comes from
fetch. When you retrieve data from a database (e.g., via Prisma, Drizzle, or direct queries), fetch is not involved and the standard Data Cache doesn't work. That's what unstable_cache is for:1import { unstable_cache } from 'next/cache';
2
3// Caching a database query
4const getCachedUser = unstable_cache(
5 async (userId: string) => {
6 // This query will be cached
7 const user = await db.user.findUnique({
8 where: { id: userId },
9 include: { posts: true, profile: true }
10 });
11 return user;
12 },
13 // Cache key — an array of strings identifying this cache
14 ['user-data'],
15 {
16 // Tags for on-demand revalidation
17 tags: ['users'],
18 // Revalidation time in seconds
19 revalidate: 3600
20 }
21);
22
23// Usage in a Server Component
24export default async function UserProfile({ params }: { params: Promise<{ id: string }> }) {
25 const user = await getCachedUser((await params).id);
26
27 return (
28 <div>
29 <h1>{user.name}</h1>
30 <p>{user.profile?.bio}</p>
31 <h2>Posts ({user.posts.length})</h2>
32 </div>
33 );
34}unstable_cache accepts three arguments:tags (for on-demand revalidation) and revalidate (time in seconds)Cache tags allow grouping related data and revalidating it together. This is more powerful than
revalidatePath because it works independently of URL paths.1// Product data — tagged with both a general and specific tag
2async function getProduct(id: string) {
3 const res = await fetch(`https://api.example.com/products/${id}`, {
4 next: {
5 tags: [
6 'products', // general tag — revalidates all products
7 `product-${id}` // specific tag — revalidates only this product
8 ]
9 }
10 });
11 return res.json();
12}
13
14// Category data — related to products
15async function getCategory(slug: string) {
16 const res = await fetch(`https://api.example.com/categories/${slug}`, {
17 next: {
18 tags: [
19 'categories',
20 `category-${slug}`
21 ]
22 }
23 });
24 return res.json();
25}1// app/actions.ts
2'use server';
3
4import { revalidateTag, revalidatePath } from 'next/cache';
5
6// After updating a single product
7export async function updateProduct(id: string, data: ProductData) {
8 await db.product.update({ where: { id }, data });
9
10 // Revalidate only this specific product
11 revalidateTag(`product-${id}`);
12
13 // Also revalidate the product list (e.g., the shop page)
14 revalidateTag('products');
15}
16
17// After a bulk price update
18export async function bulkUpdatePrices() {
19 await db.product.updateMany({ /* ... */ });
20
21 // One command revalidates ALL products across all pages
22 revalidateTag('products');
23}| Method | Scope | When to use | |--------|-------|-------------| |
revalidateTag('products') | All data with the 'products' tag, regardless of page | When data appears on multiple pages |
| revalidatePath('/products') | The entire /products page — all data on it | When you want to refresh a specific page |
| revalidatePath('/products', 'layout') | The page + all subpages with this layout | When the layout or shared data has changed |These three cache layers are often confused. Here are the key differences:
Stores the results of
fetch on the server. You control it through:1// Data cached permanently (default)
2fetch(url);
3
4// Data cached for 60 seconds
5fetch(url, { next: { revalidate: 60 } });
6
7// Data never cached
8fetch(url, { cache: 'no-store' });Lifetime: Survives between requests and deployments (unless revalidated).
Stores generated HTML + RSC Payload of static pages. Created during
next build.1// This page will be in the Full Route Cache (static)
2export default async function AboutPage() {
3 return <h1>About Us</h1>;
4}
5
6// This page will NOT be in the Full Route Cache (dynamic)
7export default async function DashboardPage() {
8 const session = await getSession(); // dynamic function
9 return <h1>Welcome, {session.user.name}</h1>;
10}
11
12// Forcing Full Route Cache revalidation
13export const revalidate = 3600; // page revalidated every hourLifetime: Until the next
next build or revalidation.Stores visited pages in the user's browser. Thanks to it, navigating back is instant.
1'use client';
2
3import { useRouter } from 'next/navigation';
4
5export function RefreshButton() {
6 const router = useRouter();
7
8 return (
9 <button onClick={() => {
10 // Clear the Router Cache and fetch fresh data from the server
11 router.refresh();
12 }}>
13 Refresh data
14 </button>
15 );
16}Lifetime:
An important distinction that many developers miss:
Works only within a single server request. If component A and component B call the same
fetch, it will be executed only once:1// Both components share the same fetch within a single render
2async function Header() {
3 const user = await getUser(); // fetch #1
4 return <nav>{user.name}</nav>;
5}
6
7async function Sidebar() {
8 const user = await getUser(); // same fetch — from memoization, not the network!
9 return <aside>{user.avatar}</aside>;
10}Survives between requests from different users:
1async function getPopularPosts() {
2 // This data is shared between ALL users
3 const res = await fetch('https://api.example.com/popular', {
4 next: { revalidate: 300 } // cache for 5 minutes
5 });
6 return res.json();
7}Cache problems are among the hardest to diagnose. Here are proven techniques:
1export default async function Page() {
2 console.log('[Page] Rendering at:', new Date().toISOString());
3
4 const data = await getData();
5 console.log('[Page] Data fetched, first item:', data[0]?.id);
6
7 return <div>{/* ... */}</div>;
8}If you see the same timestamp when refreshing — the page is served from the Full Route Cache.
In production mode, Next.js adds the
x-nextjs-cache header to responses:HIT — page from the Full Route CacheMISS — page rendered liveSTALE — page from cache, but revalidation was triggered in the background1import { headers } from 'next/headers';
2
3export default async function DebugPage() {
4 // Using await headers() forces dynamic rendering
5 const headersList = await headers();
6
7 return (
8 <div>
9 <p>Rendered: {new Date().toISOString()}</p>
10 <p>This timestamp should change on every refresh</p>
11 </div>
12 );
13}Advanced caching strategies in Next.js create a multi-layered system that — when well configured — can radically speed up an application. Key takeaways:
unstable_cache — allows caching data from databases and other non-fetch sourcesrevalidateTag for data, revalidatePath for pagesx-nextjs-cache headers, forcing dynamic rendering