Next.js course Β· Module 5: Rendering Strategies

Advanced Caching Strategies in Next.js

7 min read
In this lesson7

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.

Cache Layer Architecture in Next.js

The Next.js App Router implements a multi-layered caching system. Each layer has a different purpose and different lifetime:

  1. Request Memoization - an in-memory cache for the duration of a single server request. If the same fetch is called multiple times within a single render (e.g., in different components), Next.js will execute it only once.
  2. Data Cache - a persistent cache of data fetched via fetch. It survives between user requests and even between deployments. This is the cache you control through revalidate and cache: 'no-store'.
  3. Full Route Cache - a cache of entire statically generated pages (SSG/ISR). It stores ready-made HTML and React Server Component Payload on the server.
  4. Router Cache - a browser-side cache. It stores visited pages and prefetched segments so that client-side navigation is instant.

How the Layers Work Together

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 API

Understanding this flow allows you to precisely control which data is fresh and which can be served from cache.

unstable_cache - Caching Without fetch

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:

  • An async function - the operation to cache
  • Cache key - an array of strings forming a unique identifier
  • Options - tags (for on-demand revalidation) and revalidate (time in seconds)

Cache Tags - Precise Revalidation Control

Cache tags allow grouping related data and revalidating it together. This is more powerful than revalidatePath because it works independently of URL paths.

Data Tagging Strategy

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}

On-demand Revalidation with Tags

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}

revalidateTag vs revalidatePath - When to Use Which

MethodScopeWhen to use
revalidateTag('products')All data with the 'products' tag, regardless of pageWhen data appears on multiple pages
revalidatePath('/products')The entire /products page - all data on itWhen you want to refresh a specific page
revalidatePath('/products', 'layout')The page + all subpages with this layoutWhen the layout or shared data has changed

Data Cache vs Full Route Cache vs Router Cache

These three cache layers are often confused. Here are the key differences:

Data Cache

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).

Full Route Cache

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 hour

Lifetime: Until the next next build or revalidation.

Router Cache

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:

  • Static pages: 5 minutes
  • Dynamic pages: 30 seconds
  • Prefetched: until navigation

Cache Scope - Per-request vs Cross-request

An important distinction that many developers miss:

Per-request (Request Memoization)

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}

Cross-request (Data Cache)

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}

Debugging Cache

Cache problems are among the hardest to diagnose. Here are proven techniques:

1. Logging in Server Components

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.

2. The x-nextjs-cache Header

In production mode, Next.js adds the x-nextjs-cache header to responses:

  • HIT - page from the Full Route Cache
  • MISS - page rendered live
  • STALE - page from cache, but revalidation was triggered in the background

3. Forcing Dynamic Rendering for Testing

1import { 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}

Summary

Advanced caching strategies in Next.js create a multi-layered system that - when well configured - can radically speed up an application. Key takeaways:

  1. 4 cache layers - Request Memoization, Data Cache, Full Route Cache, Router Cache - each with a different scope and lifetime
  2. unstable_cache - allows caching data from databases and other non-fetch sources
  3. Cache tags - precise revalidation control: revalidateTag for data, revalidatePath for pages
  4. Per-request vs cross-request - Request Memoization works within a single render, Data Cache across requests
  5. Debugging - logging timestamps, x-nextjs-cache headers, forcing dynamic rendering
Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Cache layers in the Next.js App Router
4// Request Memoization -> Data Cache -> Full Route Cache -> Router Cache
5
6type CacheLayer = 'request-memo' | 'data-cache' | 'full-route' | 'router-cache';
7
8interface LayerInfo {
9  id: CacheLayer;
10  name: string;
11  scope: string;
12  lifetime: string;
13  description: string;
14  invalidation: string;
15  example: string;
16}
17
18const layers: LayerInfo[] = [
19  {
20    id: 'request-memo',
21    name: 'Request Memoization',
22    scope: 'Per-request (server)',
23    lifetime: 'Duration of a single render',
24    description: 'Deduplicates identical fetches within a single request. If Header and Sidebar both call getUser(), the fetch runs once.',
25    invalidation: 'Automatically after rendering finishes',
26    example: 'const user = await getUser(); // called 3x, executed 1x',
27  },
28  {
29    id: 'data-cache',
30    name: 'Data Cache',
31    scope: 'Cross-request (server)',
32    lifetime: 'Between requests and deployments',
33    description: 'Persistent cache of fetch results. Controlled by revalidate and cache options. Shared between users.',
34    invalidation: 'revalidateTag() / revalidatePath() / revalidate: N',
35    example: "fetch(url, { next: { revalidate: 60, tags: ['products'] } })",
36  },
37  {
38    id: 'full-route',
39    name: 'Full Route Cache',
40    scope: 'Static pages (server)',
41    lifetime: 'Until the next build or revalidation',
42    description: 'Cache of ready-made HTML + RSC Payload for static pages. Created during next build.',
43    invalidation: 'revalidatePath() / next build / revalidate on the page',
44    example: "export const revalidate = 3600; // entire page every hour",
45  },
46  {
47    id: 'router-cache',
48    name: 'Router Cache',
49    scope: "User's browser",
50    lifetime: 'Static: 5min, Dynamic: 30s',
51    description: 'Browser cache for client-side navigation. Prefetched pages load instantly.',
52    invalidation: 'router.refresh() / automatically after time passes',
53    example: "router.refresh(); // clear the Router Cache",
54  },
55];
56
57export default function CacheLayersDemo() {
58  const [selected, setSelected] = useState<LayerInfo>(layers[0]);
59  const [requestFlow, setRequestFlow] = useState<{ layer: string; result: 'HIT' | 'MISS' }[]>([]);
60
61  const simulateRequest = () => {
62    const flow: { layer: string; result: 'HIT' | 'MISS' }[] = [];
63    let resolved = false;
64
65    for (const layer of layers) {
66      if (resolved) break;
67      const hit = Math.random() > 0.4;
68      flow.push({ layer: layer.name, result: hit ? 'HIT' : 'MISS' });
69      if (hit) resolved = true;
70    }
71
72    if (!resolved) {
73      flow.push({ layer: 'Origin API', result: 'MISS' });
74    }
75
76    setRequestFlow(flow);
77  };
78
79  return (
80    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
81      <h1 style={{ color: '#64ffda', fontSize: 18 }}>Cache Layers in Next.js</h1>
82
83      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', margin: '16px 0' }}>
84        {layers.map((l, i) => (
85          <button key={l.id} onClick={() => setSelected(l)} style={{
86            background: selected.id === l.id ? '#1565c0' : '#1a2744',
87            color: '#fff', border: `1px solid ${selected.id === l.id ? '#64ffda' : '#333'}`,
88            borderRadius: 6, padding: '8px 12px', cursor: 'pointer', fontSize: 11, fontFamily: 'monospace'
89          }}>
90            {i + 1}. {l.name}
91          </button>
92        ))}
93      </div>
94
95      <div style={{ background: '#0d1117', borderRadius: 8, padding: 16, marginBottom: 16 }}>
96        <div style={{ color: '#64ffda', fontSize: 15, marginBottom: 8 }}>{selected.name}</div>
97        <div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
98          <div><span style={{ color: '#888' }}>Scope:</span> <span style={{ color: '#ce93d8' }}>{selected.scope}</span></div>
99          <div><span style={{ color: '#888' }}>Lifetime:</span> <span style={{ color: '#ffab40' }}>{selected.lifetime}</span></div>
100          <div><span style={{ color: '#888' }}>Description:</span> {selected.description}</div>
101          <div><span style={{ color: '#888' }}>Invalidation:</span> <span style={{ color: '#ef5350' }}>{selected.invalidation}</span></div>
102          <code style={{ color: '#4caf50', background: '#1a2744', padding: '6px 10px', borderRadius: 4, fontSize: 11 }}>
103            {selected.example}
104          </code>
105        </div>
106      </div>
107
108      <button onClick={simulateRequest} style={{
109        background: '#4caf50', color: '#fff', border: 'none', borderRadius: 6,
110        padding: '8px 16px', cursor: 'pointer', fontFamily: 'monospace'
111      }}>
112        Simulate request
113      </button>
114
115      {requestFlow.length > 0 && (
116        <div style={{ marginTop: 16 }}>
117          <div style={{ color: '#888', fontSize: 12, marginBottom: 8 }}>Request flow through the cache layers:</div>
118          <div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
119            {requestFlow.map((step, i) => (
120              <React.Fragment key={i}>
121                {i > 0 && <span style={{ color: '#555' }}>-&gt;</span>}
122                <div style={{
123                  background: step.result === 'HIT' ? '#1b5e20' : '#b71c1c',
124                  borderRadius: 4, padding: '4px 8px', fontSize: 11
125                }}>
126                  {step.layer}: {step.result}
127                </div>
128              </React.Fragment>
129            ))}
130          </div>
131        </div>
132      )}
133    </div>
134  );
135}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is unstable_cache used for in Next.js App Router?

  2. 2. generateStaticParams is used for:

Hands-on tasks in the game

  • Code editor

    Create a page with optimization: lazy loading, Suspense, proper rendering strategy selection

  • Horizontal ordering

    Arrange the dynamic export syntax in Next.js

  • Click in order

    Arrange the Suspense component syntax with fallback in Next.js

  • Code editor

    Create a Server Action for form submission handling with validation and error handling

Useful articles