Next.js’s caching is so pervasive that many developers are unaware of its existence, letting it subtly optimize their applications or, conversely, cause baffling stale data issues.
Let’s watch this in action. Imagine a simple pages/api/time.js route:
// pages/api/time.js
export default function handler(req, res) {
const currentTime = new Date().toLocaleTimeString();
res.status(200).json({ time: currentTime });
}
If you build and run a Next.js app with this API route, and then repeatedly hit GET /api/time in your browser or with curl, you’ll notice something odd: the time doesn’t update on every request. It seems to be cached. This is the first layer.
Layer 1: HTTP Caching (Browser & CDN)
This is the most familiar layer, operating at the HTTP protocol level. Your browser, and any intermediate CDNs, will cache responses based on standard HTTP headers like Cache-Control, Expires, and ETag. Next.js, by default, doesn’t aggressively set these headers for API routes unless you explicitly tell it to. However, build outputs for static assets (like _next/static/chunks/*.js) are heavily cached by browsers.
The surprising part here is how Next.js’s build process influences this. When you run next build, it generates static files. Browsers cache these aggressively because they are immutable. If your JavaScript code changes, the filename changes (e.g., chunk.abcdef.js becomes chunk.ghijkl.js), forcing a cache miss and a fresh download. This is why you don’t typically see stale JavaScript on new page loads.
To control this for API routes, you’d manually set headers in your API handler. For example, to disable caching for our /api/time route:
// pages/api/time.js
export default function handler(req, res) {
const currentTime = new Date().toLocaleTimeString();
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache'); // For HTTP/1.0 compatibility
res.setHeader('Expires', '0'); // Proxies
res.status(200).json({ time: currentTime });
}
This tells the browser and any intermediaries not to cache the response, ensuring a fresh result on every request.
Layer 2: Next.js Data Cache (On-Demand)
This is where things get Next.js-specific and powerful. Next.js has an "On-Demand Cache" that can cache the results of data fetching functions (like fetch calls made within getServerSideProps, getStaticProps, or Route Handlers). This cache is keyed by the URL and options used in the fetch request.
Crucially, fetch in Next.js is patched to automatically use this cache. If you call fetch('https://api.example.com/data') multiple times within the same request lifecycle, or even across different requests (if the data hasn’t been invalidated), it will only hit the actual network resource once.
Let’s modify our example to use fetch in a Route Handler, demonstrating this internal cache. First, we need a mock API to fetch from.
// pages/api/mock-data.js
let counter = 0;
export default function handler(req, res) {
counter++;
const currentTime = new Date().toLocaleTimeString();
res.status(200).json({ message: `Data fetched ${counter} times`, time: currentTime });
}
Now, a Route Handler that fetches from it:
// app/api/fetch-time/route.js
import { NextResponse } from 'next/server';
export async function GET() {
// This fetch call will use the Next.js Data Cache
const res = await fetch('http://localhost:3000/api/mock-data');
const data = await res.json();
return NextResponse.json(data);
}
If you hit GET /api/fetch-time repeatedly, you’ll observe that the Data fetched X times counter only increments occasionally. This is because the fetch call to /api/mock-data is being served from Next.js’s internal data cache. The next option on fetch controls its caching behavior:
fetch(url, { next: { revalidate: 3600 } }): Revalidate the data at most once every hour.fetch(url, { next: { tags: ['my-tag'] } }): Cache indefinitely until explicitly revalidated usingrevalidateTag('my-tag').fetch(url, { cache: 'no-store' }): Disable caching for this specific fetch.
By default, fetch in Next.js is cached indefinitely. To make our fetch call not use the cache, we’d do:
// app/api/fetch-time/route.js
import { NextResponse } from 'next/server';
export async function GET() {
const res = await fetch('http://localhost:3000/api/mock-data', { cache: 'no-store' });
const data = await res.json();
return NextResponse.json(data);
}
Now, every request to /api/fetch-time will hit /api/mock-data and increment the counter.
Layer 3: Full Route Cache (Server)
This cache stores the rendered HTML of dynamic routes (pages that use getServerSideProps or dynamic segments) and static routes (getStaticProps). When a page is requested, Next.js first checks if a cached version of the rendered HTML exists. If it does, and it’s still valid, it’s served directly from the server’s memory or disk, bypassing the need to run getServerSideProps or getStaticProps again.
This is enabled by default for static exports and pages using getStaticProps. For pages using getServerSideProps, it’s disabled by default but can be enabled via export const dynamic = 'auto' or export const dynamic = 'force-static' in the page.
Consider a page pages/ssr-time.js:
// pages/ssr-time.js
export async function getServerSideProps() {
const currentTime = new Date().toLocaleTimeString();
return {
props: { time: currentTime },
};
}
export default function Ssrtimes({ time }) {
return <div>Current time: {time}</div>;
}
If you visit this page repeatedly, you’ll notice the time doesn’t update on every reload. This is because the HTML output of getServerSideProps is being cached by Next.js. To disable this full route cache for this page, you’d set export const dynamic = 'force-dynamic':
// pages/ssr-time.js
export async function getServerSideProps() {
const currentTime = new Date().toLocaleTimeString();
return {
props: { time: currentTime },
};
}
export const dynamic = 'force-dynamic'; // Disables full route caching
export default function Ssrtimes({ time }) {
return <div>Current time: {time}</div>;
}
Now, getServerSideProps will run on every request.
Layer 4: React Server Components (RSC) Cache
In the App Router, React Server Components (RSCs) introduce a new layer of caching. The output of Server Components is cached. This cache is managed by Next.js and is highly granular. When a Server Component renders, its output (which is a special format describing the UI) is cached. Subsequent requests for the same Server Component can hit this cache.
This cache is automatically managed. When you update a Server Component, Next.js invalidates the relevant cache entries. You can also manually invalidate it using revalidateTag or revalidatePath.
Imagine an app/server-time/page.js component:
// app/server-time/page.js
async function getTime() {
const res = await fetch('http://localhost:3000/api/mock-data', {
next: { tags: ['clock-tag'] } // Tagging for potential revalidation
});
const data = await res.json();
return data.time;
}
export default async function ServerTimePage() {
const time = await getTime();
return <div>Server Component Time: {time}</div>;
}
If you visit this page, the time will appear static. This is because the output of ServerTimePage (which includes the result of getTime) is cached. The fetch call within getTime is also using the data cache (Layer 2).
To force this RSC to re-render and fetch fresh data, you would typically trigger a revalidation:
// app/api/revalidate/route.js
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
export async function GET() {
revalidateTag('clock-tag');
return NextResponse.json({ revalidated: true });
}
Hitting GET /api/revalidate will invalidate the cache for anything tagged with clock-tag. The next visit to /server-time will then fetch new data and render a fresh Server Component output.
The most counterintuitive aspect of RSC caching is that it’s not just about re-fetching data; it’s about caching the description of the UI. When a Server Component renders, it produces a serialized representation of the UI tree. This representation is what gets cached. If the underlying data fetching changes, but the resulting UI structure is the same, the cached RSC output might still be served.
The next hurdle you’ll encounter after mastering these caching layers is understanding how they interact with client-side data fetching and state management libraries.