Next.js Caching Strategies: Data Cache, Full Route Cache, Router Cache, and On-Demand Revalidation
Master Next.js App Router caching: understand the four cache layers (Request Memoization, Data Cache, Full Route Cache, Router Cache), configure revalidation strategies, and implement on-demand cache invalidation with tags.
Next.js App Router has four distinct caching layers that operate independently. Most developers know about one or two, miss the others, and then wonder why their data is stale or why invalidation isn't working.
Understanding all four layers โ and how they interact โ is the difference between a site that's reliably fast and one that shows users stale data in confusing patterns.
The Four Cache Layers
1. Request Memoization Per-render, in-memory
Scope: single request | Duration: one render pass
Purpose: deduplicate identical fetch() calls within one Server Component tree
2. Data Cache Persistent, server-side
Scope: server process | Duration: configurable (revalidate option)
Purpose: cache fetch() responses between requests
3. Full Route Cache Persistent, build-time or runtime
Scope: server process | Duration: until revalidated or redeployed
Purpose: cache entire rendered HTML + RSC payload for a route
4. Router Cache Client-side, in-memory
Scope: browser tab | Duration: session (30sโ5min per route)
Purpose: cache RSC payloads on the client for instant navigation
Layer 1: Request Memoization
Request Memoization is automatic and transparent. The same fetch() URL called multiple times in one render only hits the network once:
// src/app/dashboard/page.tsx
// Both components call getUser(userId) โ only ONE network request is made
async function getUser(userId: string) {
const res = await fetch(`${process.env.API_URL}/users/${userId}`, {
// Request Memoization works by default
// No special configuration needed
});
return res.json();
}
async function UserHeader({ userId }: { userId: string }) {
const user = await getUser(userId); // Fetch #1 โ hits network
return <h1>{user.name}</h1>;
}
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId); // Fetch #2 โ served from memoization cache
return <div>{user.email}</div>;
}
export default async function DashboardPage({
params,
}: {
params: { userId: string };
}) {
// Both components call getUser โ only one HTTP request is made
return (
<>
<UserHeader userId={params.userId} />
<UserProfile userId={params.userId} />
</>
);
}
Memoization only works for GET requests and only within a single render tree.
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Layer 2: Data Cache
The Data Cache persists fetch() responses on the server across multiple requests:
// src/lib/fetchers.ts
// Default: cached indefinitely (until revalidated or redeployed)
async function getStaticContent(slug: string) {
const res = await fetch(`${process.env.CMS_URL}/content/${slug}`);
// Equivalent to: { cache: 'force-cache' }
return res.json();
}
// Time-based revalidation (ISR equivalent)
async function getBlogPosts() {
const res = await fetch(`${process.env.CMS_URL}/posts`, {
next: { revalidate: 3600 }, // Revalidate every hour
});
return res.json();
}
// No caching โ always fetch fresh data
async function getCurrentPrice(ticker: string) {
const res = await fetch(`${process.env.MARKET_API}/price/${ticker}`, {
cache: "no-store", // Bypass Data Cache entirely
});
return res.json();
}
// Tag-based invalidation โ most powerful pattern
async function getProduct(productId: string) {
const res = await fetch(`${process.env.API_URL}/products/${productId}`, {
next: {
revalidate: false, // Don't use time-based revalidation
tags: [`product:${productId}`, "products"], // Associate with cache tags
},
});
return res.json();
}
Layer 3: Full Route Cache
The Full Route Cache stores the rendered HTML and RSC payload for entire routes. For static routes, this is built at next build. For dynamic routes, it's cached on first request.
// src/app/blog/[slug]/page.tsx
// Static routes: cached at build time
// Dynamic routes: cached on first visit, revalidated per strategy
// Force static generation
export const dynamic = "force-static";
// Force dynamic (opt out of Full Route Cache entirely)
export const dynamic = "force-dynamic";
// Time-based revalidation for the whole route
export const revalidate = 3600; // Revalidate every hour
// generateStaticParams: which routes to pre-build
export async function generateStaticParams() {
const posts = await getBlogPosts();
return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}
export default async function BlogPost({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug); // Data Cache + Full Route Cache
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
What Makes a Route Dynamic (Opts Out of Full Route Cache)
// Any of these cause a route to be dynamic:
// 1. Reading request-specific headers/cookies
import { headers, cookies } from "next/headers";
const cookieStore = cookies(); // Dynamic
// 2. Using searchParams
export default function Page({ searchParams }: { searchParams: { q: string } }) {
// searchParams makes the route dynamic
}
// 3. Using noStore() from cache
import { unstable_noStore as noStore } from "next/cache";
noStore(); // Opts this render out of Full Route Cache
// 4. fetch() with cache: 'no-store'
const res = await fetch(url, { cache: "no-store" }); // Dynamic
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Layer 4: Router Cache
The Router Cache is client-side and caches RSC payloads in the browser for instant navigation:
Duration:
- Static routes: 5 minutes
- Dynamic routes: 30 seconds
The Router Cache is invisible to your code โ it operates automatically
in the browser's memory when using <Link /> for navigation.
// src/components/Navigation.tsx
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
// Link prefetches and caches the route payload on hover
// router.prefetch() manually warms the Router Cache
export function Navigation() {
const router = useRouter();
return (
<nav>
{/* Prefetches /dashboard on hover */}
<Link href="/dashboard">Dashboard</Link>
{/* Programmatic prefetch */}
<button
onMouseEnter={() => router.prefetch("/reports")}
onClick={() => router.push("/reports")}
>
Reports
</button>
</nav>
);
}
On-Demand Revalidation
On-demand revalidation invalidates specific cache entries when data changes โ no need to redeploy:
// src/app/api/revalidate/route.ts
// Webhook endpoint called by your CMS when content changes
import { revalidatePath, revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
// Validate the request is from your CMS
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.REVALIDATION_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { type, id, slug } = body;
switch (type) {
case "blog_post.updated":
// Invalidate specific post and the blog listing
revalidatePath(`/blog/${slug}`);
revalidatePath("/blog");
revalidateTag(`post:${id}`);
break;
case "product.updated":
// Invalidate product page and any pages showing this product
revalidateTag(`product:${id}`);
revalidateTag("products");
revalidatePath("/shop");
break;
case "global.settings.updated":
// Invalidate everything โ nuclear option
revalidatePath("/", "layout");
break;
}
return NextResponse.json({ revalidated: true, now: Date.now() });
}
revalidatePath vs revalidateTag
// revalidatePath: invalidates all data fetched for a specific URL
revalidatePath("/blog/my-post"); // Specific page
revalidatePath("/blog"); // Blog index
revalidatePath("/blog", "page"); // All pages matching /blog/[...slug]
revalidatePath("/", "layout"); // Root layout (affects ALL pages)
// revalidateTag: invalidates all fetch() calls that used this tag
// More precise โ only invalidates data, not the whole route render
revalidateTag("products"); // All fetches tagged "products"
revalidateTag(`product:${productId}`); // One specific product
// Use tags when:
// - Same data appears on multiple pages
// - You want fine-grained invalidation without rebuilding whole routes
// Use paths when:
// - Data is specific to one URL
// - You know exactly which route to invalidate
Caching Strategy by Route Type
| Route Type | Data Cache | Full Route Cache | Strategy |
|---|---|---|---|
| Marketing pages | revalidate: 3600 | Static | Build-time + hourly revalidation |
| Blog posts | revalidate: false + tags | Cached | Tag-based on CMS webhook |
| E-commerce product pages | revalidate: 300 + tags | Cached | Inventory tag + 5-min TTL |
| User dashboard | cache: 'no-store' | Dynamic | Always fresh, user-specific |
| Real-time data | cache: 'no-store' | Dynamic | No cache at any layer |
| Admin pages | cache: 'no-store' | Dynamic | No cache, auth required |
Debugging the Cache
// src/app/api/cache-debug/route.ts
// Add to non-production environments only
import { NextResponse } from "next/server";
export async function GET() {
const res = await fetch(`${process.env.API_URL}/status`, {
next: { tags: ["debug-test"], revalidate: 60 },
});
return NextResponse.json({
data: await res.json(),
cacheStatus: res.headers.get("x-nextjs-cache"), // HIT | MISS | SKIP | STALE
age: res.headers.get("age"),
cacheControl: res.headers.get("cache-control"),
});
}
# Check which routes are static vs dynamic in the build output
next build 2>&1 | grep -E "โ|โ|ฦ"
# โ = Static (Full Route Cache hit at build time)
# โ = Static with revalidation (ISR)
# ฦ = Dynamic (server-rendered on each request)
See Also
- Next.js App Router Patterns โ App Router architecture
- Next.js Performance Optimization โ PPR, streaming
- React Server Actions โ mutations that trigger revalidation
- CDN and Edge Caching โ CDN layer caching
Working With Viprasol
Next.js caching is powerful but requires deliberate configuration for each route type. Wrong defaults lead to stale data in user dashboards or cache misses on pages that should be static. Our Next.js engineers configure the full caching stack โ Data Cache, Full Route Cache, Router Cache, and on-demand revalidation โ so your app is fast and correct.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.