How Caching Works in Next.js v13+ with App Router

Caching in Next.js 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

Example usage of Next.js

Source: Next.js Documentation

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 or revalidateTag().

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 } or cache: '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' or no-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 both layout.tsx and page.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 TypeWhere it worksControlExample
Full Route CacheServer (entire route)revalidate, tagexport const revalidate = 60
Data CacheServer (fetch)cache, revalidatefetch(..., { next: ... })
Static Gen CacheServer (build time)NoneJSX without data
Client-Side CacheBrowser / clientYouSWR / React Query

Real-life example

Suppose you are building a shop:

PageWhat to cacheWhich cache type to use
/productsProduct list, updates every 5 minutesFull Route Cache (ISR)
/api/productsProduct API dataData Cache (with revalidateTag)
SPA navigationFast navigationRouter 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() or revalidateTag()