DEV Community

Tamiz Uddin
Tamiz Uddin

Posted on Originally published at tamiz.pro

Advanced Server-Side Caching Patterns in Next.js: A Deep Dive into Data Cache, Full Route Cache, and Revalidation Strategies

Originally published on tamiz.pro.

Next.js 15's App Router ships with four distinct caching layers, and confusing them is the single most common source of stale data, memory leaks, and mysterious 404s in production. This deep dive maps every layer — Request Memoization, the Data Cache, the Full Route Cache, and the Router Cache — then walks through the advanced patterns that actually work in production: tag-based invalidation, unstable_cache with custom keys, time-based revalidation (ISR), and cache-aware streaming. By the end, you'll know exactly which cache is serving a request, why it's serving it, and how to invalidate it deterministically.

1. The Four Caching Layers: A Mental Model

Every request in the App Router passes through up to four independent caches. They have different lifetimes, different scopes, and different invalidation triggers. Treating them as one "Next.js cache" is the root of nearly every caching bug.

┌─────────────────────────────────────────────────────────────┐
│                        Browser (Client)                    │
│  ┌───────────────────────────────────────────────────────┐ │
│  │  Layer 4: Router Cache (client-side, ~30s default)    │ │
│  │  Stores RSC payloads for back/forward navigation      │ │
│  └───────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
                           │ HTTP request
┌──────────────────────────▼──────────────────────────────────┐
│                       Next.js Server                        │
│  ┌───────────────────────────────────────────────────────┐ │
│  │  Layer 3: Full Route Cache (static HTML per route)    │ │
│  │  Persists the rendered HTML + RSC payload on disk     │ │
│  └───────────────────────────────────────────────────────┘ │
│  ┌───────────────────────────────────────────────────────┐ │
│  │  Layer 2: Data Cache (persistent, cross-request)      │ │
│  │  Stores fetch() results + unstable_cache output       │ │
│  └───────────────────────────────────────────────────────┘ │
│  ┌───────────────────────────────────────────────────────┐ │
│  │  Layer 1: Request Memoization (per-request, in-memory)│ │
│  │  Dedupes identical fetch() calls within one render    │ │
│  └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
Layer Scope Persistence Default Lifetime Primary Invalidation
Request Memoization One render pass In-memory Request ends None (automatic)
Data Cache Global (server) Persistent (disk/DB) fetch default: 0s (no store); unstable_cache: infinite revalidateTag, revalidatePath, time
Full Route Cache Per-route Persistent (disk) Static routes: infinite revalidatePath, rebuild, time
Router Cache Per-client session In-memory 30s (default) Navigation, router.refresh()

Key insight: These layers are not alternatives. A single page request can be served from the Router Cache, then the Full Route Cache, then the Data Cache, then Request Memoization — in that order. Knowing which one is holding your stale data is step one.

2. Layer 1 — Request Memoization

Request Memoization is the simplest and least understood layer. During a single server render, Next.js deduplicates identical fetch() calls (and wrapped unstable_cache calls) so that a function called twice with the same arguments hits the network once.

// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // If ProductCard and PriceChart both call fetchProduct(id),
  // the network request happens ONCE per render.
  const product = await fetchProduct(params.id);
  const price = await fetchPrice(params.id);

  return (
    <div>
      <ProductCard product={product} />
      <PriceChart price={price} />
    </div>
  );
}

async function fetchProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`);
  return res.json();
}

async function fetchPrice(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}/price`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

What it does and doesn't do

  • Dedupes identical fetch() URLs + options within a single render pass.
  • Does NOT persist across requests. The next request re-fetches.
  • Does NOT cache non-fetch data. If you query a database with prisma or pg, there is no memoization unless you wrap it in React.cache().

The React.cache() escape hatch

For non-fetch data sources (DB queries, SDK calls, file reads), you can opt into per-request memoization yourself:

// lib/db.ts
import { cache } from 'react';

export const getUser = cache(async (userId: string) => {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  return user;
});
Enter fullscreen mode Exit fullscreen mode

Now getUser('123') called ten times in one render executes the query once. This is a pure per-request optimization — it never survives the request, so it's safe to use liberally.

3. Layer 2 — The Data Cache

The Data Cache is the persistent, cross-request cache for fetch() results and unstable_cache outputs. This is where most advanced patterns live.

3.1 fetch() caching semantics in Next.js 15

Next.js 15 changed the default: fetch() is no longer cached by default. You must opt in explicitly.

// app/dashboard/page.tsx

// ❌ NOT cached (Next.js 15 default) — hits the network on every request
const res = await fetch('https://api.example.com/stats');

// ✅ Cached for 60 seconds (stale-while-revalidate)
const res = await fetch('https://api.example.com/stats', {
  next: { revalidate: 60 },
});

// ✅ Cached indefinitely until manually invalidated
const res = await fetch('https://api.example.com/stats', {
  next: { tags: ['stats'] },
});

// ✅ Never cached, always fresh
const res = await fetch('https://api.example.com/stats', {
  cache: 'no-store',
});
Enter fullscreen mode Exit fullscreen mode
Option Behavior Typical Use Case
(none) No cache (Next 15 default) Live data, auth-dependent data
next: { revalidate: N } Stale-while-revalidate, N seconds Dashboards, listings
next: { tags: [...] } Cache forever until tag invalidated CMS content, reference data
cache: 'no-store' Bypass entirely Real-time, user-specific data
cache: 'force-cache' Force cache (legacy behavior) Rare — prefer revalidate or tags

3.2 Stale-while-revalidate (SWR) in the Data Cache

When you set revalidate: 60, Next.js serves the cached value immediately and triggers a background revalidation in the next request after 60 seconds. Users never wait for the network — they get the previous data, and the fresh data is swapped in on the subsequent request.

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 300 }, // 5 minutes
  }).then((r) => r.json());

  return <ProductGrid products={products} />;
}
Enter fullscreen mode Exit fullscreen mode

Trade-off: SWR gives you sub-second TTFB at the cost of serving data that's up to revalidate seconds old. For a product catalog that's fine. For a stock ticker, it's not.

4. Layer 3 — The Full Route Cache

The Full Route Cache stores the rendered HTML and the RSC payload for statically rendered routes. It's why a static page can be served from a CDN without running any server code.

4.1 Static vs. dynamic rendering

A route is static (and thus eligible for the Full Route Cache) only if every data fetch in it is cacheable. A single cache: 'no-store' or a cookies()/headers() call makes the whole route dynamic, which disables the Full Route Cache for that route.

// app/account/page.tsx
export default async function AccountPage() {
  // ❌ This makes the ENTIRE route dynamic.
  // The Full Route Cache is bypassed; every request re-renders.
  const session = await cookies();

  const user = await fetch(`https://api.example.com/users/${session.get('uid')}`, {
    cache: 'no-store',
  });

  return <Account user={user} />;
}
Enter fullscreen mode Exit fullscreen mode

4.2 Forcing static generation with generateStaticParams

For dynamic routes, you can pre-render known parameter combinations at build time:

// app/blog/[slug]/page.tsx
import { getAllSlugs } from '@/lib/posts';

export async function generateStaticParams() {
  const slugs = await getAllSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: { revalidate: 3600 }, // 1 hour ISR
  }).then((r) => r.json());

  return <article>{post.content}</article>;
}
Enter fullscreen mode Exit fullscreen mode

Now /blog/hello-world is pre-rendered at build time, served from the Full Route Cache, and revalidated every hour — the classic ISR pattern.

5. Layer 4 — The Router Cache

The Router Cache lives in the browser. It stores the RSC payload of previously visited routes so that back/forward navigation is instant. It's ephemeral (per-client, cleared on refresh) and has a default stale time of 30 seconds.

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 0,        // dynamic routes: never stale-cache
      static: 180,       // static routes: 3 minutes
    },
  },
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Common bug: You update a database, call revalidateTag, and the server data is fresh — but the browser still shows stale UI because the Router Cache is serving the old RSC payload. The fix is router.refresh() after a mutation, or revalidatePath with type: 'layout' to force a full refetch.

6. Advanced Pattern: Tag-Based Revalidation with revalidateTag

Tags are the most powerful invalidation primitive in Next.js. They let you invalidate every cached fetch tagged with a string, from anywhere — a server action, a route handler, or a webhook — without knowing which route consumed it.

6.1 Tagging fetches

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetch('https://api.example.com/products', {
    next: { tags: ['products', 'catalog'] },
  }).then((r) => r.json());

  return <ProductGrid products={products} />;
}
Enter fullscreen mode Exit fullscreen mode

6.2 Invalidating from a Server Action

// app/products/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function deleteProduct(productId: string) {
  await prisma.product.delete({ where: { id: productId } });

  // Every fetch tagged 'products' is now stale.
  // The next request to /products will re-fetch from the origin.
  revalidateTag('products');
}
Enter fullscreen mode Exit fullscreen mode

6.3 Invalidating from a webhook (route handler)

// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const payload = await request.json();

  // Validate the webhook signature...
  if (!isValidSignature(payload)) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Invalidate the affected content — no need to know which pages use it.
  revalidateTag(payload.entityType); // e.g. 'blog-post', 'product'

  return NextResponse.json({ revalidated: true });
}
Enter fullscreen mode Exit fullscreen mode

6.4 The composite tag pattern

Tag with both a collection and a single item so you can invalidate granularly or broadly:

// lib/data.ts
const API = 'https://api.example.com';

export async function getProduct(id: string) {
  const res = await fetch(`${API}/products/${id}`, {
    next: { tags: [`product-${id}`, 'products'] },
  });
  return res.json();
}

export async function getProducts() {
  const res = await fetch(`${API}/products`, {
    next: { tags: ['products'] },
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode
// app/products/[id]/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function updateProduct(id: string, data: ProductInput) {
  await prisma.product.update({ where: { id }, data });

  // Invalidate just this product AND the whole list.
  revalidateTag(`product-${id}`);
  revalidateTag('products');
}
Enter fullscreen mode Exit fullscreen mode

Now updating one product refreshes its detail page and the listing page, but leaves other product detail pages untouched.

7. Advanced Pattern: unstable_cache with Custom Keys

fetch() only caches HTTP requests. For database queries, external SDK calls, or computed values, you need unstable_cache — the official escape hatch for arbitrary async functions.

7.1 The basic wrapper

// lib/analytics.ts
import { unstable_cache } from 'next/cache';

export const getDailyRevenue = unstable_cache(
  async (startDate: string, endDate: string) => {
    // Any async work: Prisma, Drizzle, an SDK, a file read...
    const rows = await prisma.orders.groupBy({
      by: ['date'],
      where: { date: { gte: startDate, lte: endDate } },
      _sum: { amount: true },
    });
    return rows;
  },
  // Cache key parts — must be serializable.
  ['daily-revenue'],
  // Options
  {
    revalidate: 900, // 15 minutes
    tags: ['analytics', 'revenue'],
  }
);
Enter fullscreen mode Exit fullscreen mode

7.2 The critical gotcha: arguments are NOT part of the key

unstable_cache uses only the key parts array to build the cache entry. The function's arguments are ignored for keying purposes. If you call getDailyRevenue('2025-01-01', '2025-01-31') and then getDailyRevenue('2025-02-01', '2025-02-28'), both calls return the same cached result unless you include the arguments in the key.

// ❌ WRONG — both calls share one cache entry
const getRevenue = unstable_cache(
  async (start: string, end: string) => queryRevenue(start, end),
  ['revenue'],
  { revalidate: 900 }
);

await getRevenue('2025-01-01', '2025-01-31'); // caches under ['revenue']
await getRevenue('2025-02-01', '2025-02-28'); // ❌ returns January's data!

// ✅ CORRECT — include the args in the key parts
const getRevenue = unstable_cache(
  async (start: string, end: string) => queryRevenue(start, end),
  (start, end) => ['revenue', start, end],  // dynamic key function
  { revalidate: 900 }
);

await getRevenue('2025-01-01', '2025-01-31'); // caches under ['revenue', '2025-01-01', '2025-01-31']
await getRevenue('2025-02-01', '2025-02-28'); // ✅ separate entry
Enter fullscreen mode Exit fullscreen mode

The key function receives the same arguments as the wrapped function and must return a serializable array. This is the #1 source of subtle bugs with unstable_cache.

7.3 Combining unstable_cache with React.cache()

Use both for maximum safety: React.cache() dedupes within a request; unstable_cache persists across requests.

// lib/user.ts
import { cache } from 'react';
import { unstable_cache } from 'next/cache';

const _getUser = unstable_cache(
  async (id: string) => prisma.user.findUnique({ where: { id } }),
  (id) => [`user-${id}`],
  { tags: ['users'], revalidate: 3600 }
);

export const getUser = cache((id: string) => _getUser(id));
Enter fullscreen mode Exit fullscreen mode

Now the same user fetched in ten components during one render hits the cache once, and the cached result survives across requests for an hour.

8. Advanced Pattern: Time-Based Revalidation (ISR) Done Right

Incremental Static Regeneration (ISR) is the time-based counterpart to tag-based invalidation. It's ideal when you can tolerate up to N seconds of staleness but want zero server load on the critical path.

8.1 Per-fetch revalidation vs. per-route revalidation

You can set revalidation at the fetch level (as we've seen) or at the route level via the revalidate export. Route-level revalidation applies to all fetches in the route that don't specify their own.

// app/blog/page.tsx
// Applies to every fetch in this route unless overridden.
export const revalidate = 3600; // 1 hour

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts'); // inherits 3600
  return <PostList posts={posts} />;
}
Enter fullscreen mode Exit fullscreen mode

8.2 On-demand revalidation with revalidatePath

Time-based ISR is passive. When you need to invalidate immediately (e.g. a user publishes a post), use revalidatePath:

// app/admin/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function publishPost(postId: string) {
  await prisma.post.update({ where: { id: postId }, data: { published: true } });

  // Revalidate the post page and the blog index immediately.
  revalidatePath(`/blog/${postId}`);
  revalidatePath('/blog');
}
Enter fullscreen mode Exit fullscreen mode

8.3 The hybrid pattern: time + tags

For most real apps, combine both: use tags for deterministic invalidation, and set a generous revalidate as a safety net so stale data self-heals even if a webhook fails.

const getProduct = unstable_cache(
  async (id: string) => fetchProductFromDb(id),
  (id) => [`product-${id}`],
  {
    revalidate: 86400,   // 24h safety net
    tags: ['products', 'catalog'],
  }
);
Enter fullscreen mode Exit fullscreen mode

This gives you: instant invalidation via webhooks/actions, plus automatic recovery within 24 hours if something goes wrong. This is the pattern I recommend for most production CMS-driven sites.

9. Advanced Pattern: Cache-Aware Streaming and Suspense

Streaming with Suspense changes how caching behaves. When a route is static and you stream a component, the Full Route Cache stores the initial HTML shell and the entire RSC payload — including the streamed content — so the second visit is instant.

9.1 Streaming a cached fetch

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<Skeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

async function RevenueChart() {
  // This fetch is cached. The component streams in, but the RSC payload
  // is stored in the Full Route Cache so the next visit is instant.
  const data = await fetch('https://api.example.com/revenue', {
    next: { revalidate: 300, tags: ['revenue'] },
  }).then((r) => r.json());

  return <Chart data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

9.2 The dynamic export — controlling the whole route

Sometimes you need to force a route out of the Full Route Cache entirely (e.g. an admin dashboard showing live data). Use the dynamic export:

// app/admin/overview/page.tsx
// Force dynamic rendering — bypasses Full Route Cache for this route.
export const dynamic = 'force-dynamic';

export default async function AdminOverview() {
  const stats = await fetch('https://api.example.com/admin/stats', {
    cache: 'no-store',
  }).then((r) => r.json());

  return <StatsPanel stats={stats} />;
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: Use force-dynamic sparingly. It disables all static optimization for the route, including the shell, which can tank performance on high-traffic pages. Prefer per-fetch no-store or short revalidate values instead.

10. Production Best Practices and Common Pitfalls

10.1 Pitfall: The Router Cache is the invisible culprit

You call revalidateTag, your server data is fresh, but the UI doesn't update. That's the Router Cache serving a stale RSC payload. Fix: call router.refresh() after mutations, or set staleTimes.dynamic: 0 for routes that must always be fresh.

10.2 Pitfall: unstable_cache key collisions

Forgetting to include function arguments in the cache key causes cross-argument data bleed. Always use the dynamic key function form when the function takes arguments.

10.3 Pitfall: Mixing no-store with revalidate in one route

A single no-store fetch makes the whole route dynamic and disables the Full Route Cache — even if other fetches are cached. This silently kills ISR. Check the Network tab: if the route responds with x-nextjs-cache: no-store, you've been bitten.

10.4 Best practice: Centralize cache configuration

Define your cache policies in one place so you can tune them without hunting through components:

// lib/cache-policies.ts
export const CACHE = {
  cms: { revalidate: 300, tags: ['cms'] },
  analytics: { revalidate: 900, tags: ['analytics'] },
  session: { revalidate: 0, tags: ['session'] },
  catalog: { revalidate: 3600, tags: ['catalog', 'products'] },
} as const;

// Usage:
const res = await fetch(url, { next: CACHE.catalog });
Enter fullscreen mode Exit fullscreen mode

10.5 Best practice: Log cache hits and misses in development

// lib/with-cache-log.ts
import { unstable_cache } from 'next/cache';

export function withCacheLog<T extends (...args: any[]) => Promise<any>>(
  fn: T,
  key: string[],
  options: { revalidate?: number; tags?: string[] } = {}
) {
  return unstable_cache(
    async (...args: Parameters<T>) => {
      console.log(`[cache:miss] ${key.join('/')}`);
      return fn(...args);
    },
    key,
    {
      ...options,
      // Log when the cache is populated after a miss
      revalidate: options.revalidate ?? 60,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

10.6 Best practice: Use revalidatePath for page-level, revalidateTag for data-level

revalidatePath is coarse — it invalidates the Full Route Cache for a path and everything nested. revalidateTag is fine-grained — it invalidates only the data tagged. Prefer tags for data that's shared across many routes; use paths only when you want to force a full page re-render.

Frequently Asked Questions

Q: Why is my fetch() not being cached even though I set next: { revalidate: 60 }?

A: Check three things. First, make sure the route isn't dynamic — a cookies(), headers(), or searchParams read forces dynamic rendering, which disables the Full Route Cache but not the Data Cache. Second, verify you're not passing cache: 'no-store' anywhere in the same route, which makes the whole route dynamic. Third, confirm the fetch URL is a full absolute URL — relative URLs are not cached. Inspect the response headers: x-nextjs-cache: HIT confirms the Data Cache served it.

Q: What's the difference between revalidatePath and revalidateTag?

A: revalidatePath invalidates the Full Route Cache for a specific path and all nested routes — it forces a re-render of those pages. revalidateTag invalidates the Data Cache entries tagged with that string, which can span many routes. Use revalidateTag for data shared across routes (e.g. a CMS collection), and revalidatePath when you want to guarantee a specific page's HTML is regenerated. In practice, you'll often call both.

Q: Is unstable_cache safe to use in production?

A: Yes, it's widely used in production, but the API is still marked unstable and may change in future majors. The main risk is the key-collision pitfall described in Section 7.2 — always use the dynamic key function when your wrapped function takes arguments. For a stable alternative, consider the use cache directive (experimental in Next.js 15) which brings compiler-level caching and is expected to become the standard. For more on the evolution of these APIs, see Tamiz's Insights.

Top comments (0)