How Caching Works in Next.js v13+ with App Router
A detailed look at how caching works in Next.js 13+, what types of cache exist, and how this impacts performance and SEO.
What is cache and how does it work in Next.js
Cache is a way to store the result of computations or requests so you don't have to repeat them, speeding up your application. In Next.js, HTML pages, data, requests, component rendering, and even entire routes can be cached.
With the introduction of the App Router (starting from Next.js 13), the philosophy changed: caching is now enabled by default, and it's up to the developer to disable it where dynamic behavior is needed.
The Next.js policy is as follows:
"We cache everything unless told otherwise"
If you don't explicitly specify cache: 'no-store'
, next: { revalidate }
, or dynamic = 'force-dynamic'
, then:
fetch()
will be cached- layout and page may also be cached
- routes will be added to the route cache
- page rendering will go into the Full Route Cache
This provides performance, but can lead to stale data and debugging difficulties.
Types of caching in Next.js
Full Route Cache (server-side route caching)
Used with ISR (Incremental Static Regeneration).
- The entire page is cached (HTML, data from
layout.tsx
,page.tsx
,fetch()
,headers()
,cookies()
). - The cache lives on the server.
- Works by default if there is no dynamic behavior.
- Invalidated via
revalidate
orrevalidateTag()
.
Best for blogs, marketing pages, and other rarely changing content.
Example:
export const revalidate = 60; // regenerate the page every 60 seconds
Data Cache (server-side fetch caching)
- Responses from
fetch()
in Server Components are cached. - Controlled via
next: { revalidate }
orcache: 'no-store'
.
Example:
await fetch('https://api.com/posts', {
next: { revalidate: 300 },
});
Request Memoization (per-request memory, server)
This is an internal Next.js optimization: the same fetch()
or function with the same arguments is only called once per render.
- Within a single server render, Next.js tracks identical requests.
- If they have already been called, the result is taken from in-memory cache.
- Works regardless of
cache: 'force-cache'
orno-store
.
Good for:
- DRY (don’t repeat yourself) when fetching the same data in different components.
- Improving performance without manual optimization.
- When the same
fetch
is used in bothlayout.tsx
andpage.tsx
.
Not for:
- Reuse between different requests or users (not a global cache).
- Bypassing fetch cache — it works on top of Request Memoization.
// lib/getUser.ts
export async function getUser() {
return fetch('https://api.com/user').then(res => res.json());
}
// app/layout.tsx
export default async function Layout({ children }) {
const user = await getUser(); // Call 1
return (
<div>
<Sidebar user={user} />
{children}
</div>
);
}
// app/page.tsx
import { getUser } from '../lib/getUser';
export default async function Page() {
const user = await getUser(); // Call 2, but actually not repeated
return <p>Hello, {user.name}</p>;
}
Physically, fetch will be called only once, and the result will be used in both places.
Router Cache (client-side cache)
This is a cache you control yourself using libraries like SWR, React Query, etc.
- Works only with client-side navigation (
<Link>
,router.push()
). - Next.js does not control this cache — you decide how to cache.
- Very fast, because nothing is re-rendered — React just mounts from memory.
- But it only lives until the user reloads the page (F5).
Best for interactive components, forms, dashboards.
Example:
'use client';
import useSWR from 'swr';
const { data } = useSWR('/api/data', fetcher);
Visual comparison of cache types
Cache Type | Where it works | Control | Example |
---|---|---|---|
Full Route Cache | Server (entire route) | revalidate , tag | export const revalidate = 60 |
Data Cache | Server (fetch) | cache , revalidate | fetch(..., { next: ... }) |
Static Gen Cache | Server (build time) | None | JSX without data |
Client-Side Cache | Browser / client | You | SWR / React Query |
Real-life example
Suppose you are building a shop:
Page | What to cache | Which cache type to use |
---|---|---|
/products | Product list, updates every 5 minutes | Full Route Cache (ISR) |
/api/products | Product API data | Data Cache (with revalidateTag ) |
SPA navigation | Fast navigation | Router Cache (client-side cache) |
Common cache issues in App Router
Updated data — but the site shows old data
fetch()
uses force-cache
by default if you don't specify cache: 'no-store'
or next: { revalidate }
. As a result, Next.js caches the data and never updates it.
Solution:
- Add
cache: 'no-store'
to disable caching - Or
next: { revalidate: 60 }
for ISR
fetch('https://api.com/data', { next: { revalidate: 60 } });
URL (searchParams) changed, but data is the same
Next.js does not re-render the fetch request if the URL is the same. If you use parameters, add them directly to the fetch URL:
fetch(`https://api.com/posts?category=${searchParams.category}`, {
next: { revalidate: 60 }
});
Navigated via <Link>
, but data did not update
Router Cache (client-side cache) was used. Next.js does not make a new request when returning to the page.
Solution:
- Force data refresh via
router.refresh()
- Use
cache: 'no-store'
if data must always be fresh
revalidatePath()
or revalidateTag()
does not work
- Cache was not created (e.g., due to
no-store
) - You are not using
tags
in fetch - You call
revalidatePath()
outside a server action
Solution:
- Make sure fetch uses
next: { tags: [...] }
- Call
revalidateTag('tag')
in a server function
Layout is cached and does not update
layout.tsx
is also cached as an RSC with revalidate
, and if the data inside is cached, it will not update until the next ISR.
Solution:
- Use
cache: 'no-store'
for fetch in layout.tsx if, for example, it contains a user profile - Or move fetch from layout to page (or a client component with SWR)
Unexpected behavior in development (dev)
In next dev
, many caches are disabled or work differently, and revalidate
does not work reliably in dev mode.
Tip:
Test cache with next build && next start
or Vercel preview.
Combination of cookies()
, headers()
, params
, and fetch()
breaks cache
When you use dynamic things like:
cookies()
headers()
useSearchParams()
generateMetadata() with dynamic
Next.js automatically disables page cache and switches to dynamic = 'force-dynamic'
.
Checklist: how to avoid cache issues
- Always explicitly set fetch behavior:
// no cache at all fetch(..., { cache: 'no-store' }) // ISR (revalidate every 60 seconds) fetch(..., { next: { revalidate: 60 } }) // manual invalidation via tag fetch(..., { next: { tags: ['products'] } })
- Don’t forget to include query parameters directly in the fetch URL
- Test with
next build && next start
— otherwise you might get a false sense of confidence - If you need to manually invalidate data, use
revalidatePath()
orrevalidateTag()