Caching & Revalidation in Next.js

1. Introduction 🚀

Caching is one of the most powerful — and most misunderstood — parts of Next.js. Done well, it makes your app feel instant; done poorly, it serves stale data to users who expect fresh content. This tutorial breaks down every caching layer in Next.js, from request-level memoization all the way up to the CDN, and shows you how to invalidate each one intentionally.

Information

Caching behavior has evolved significantly across Next.js versions. This guide covers the app router caching model; always cross-check version-specific defaults in the official docs.

2. What is Caching? 🤔

Caching means storing the result of expensive work — a database query, a rendered page, a fetch call — so a future request can reuse it instead of redoing that work from scratch.

  • Reduces redundant computation and network calls.
  • Improves perceived and actual page load speed.
  • Introduces a new problem: knowing when cached data is no longer valid.

3. Why Caching Matters ⚡

Without caching, every request would re-run every data fetch and re-render every component from zero — even if nothing changed since the last request a second ago. Caching trades a small risk of staleness for a large gain in speed and cost.

Tip

The core tension in this whole topic is freshness vs. performance. Nearly every caching decision in Next.js is really a decision about where you sit on that spectrum.

4. Next.js Caching System đŸ—‚ī¸

Next.js layers four distinct caching mechanisms on top of each other, each operating at a different scope and lifetime.

CacheLocationPurposeDuration
Request MemoizationServerDedupe identical fetch callsSingle render pass
Data CacheServerPersist fetch resultsUntil revalidated
Full Route CacheServerStore rendered HTML/RSC payloadUntil revalidated
Router CacheClientStore visited route segmentsSession-based

5. Request Memoization 🧠

Within a single render pass, calling fetch with the same URL and options multiple times only triggers one actual network request — the rest are automatically deduplicated.

app/page.tsx

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

export default async function Page() {
  // Both calls resolve from the same underlying request
  const userA = await getUser('1');
  const userB = await getUser('1');
  return <div>{userA.name}</div>;
}

Note

Request memoization only applies during a single server render — it does not persist between separate requests.

6. Data Cache 💾

The Data Cache persists the results of fetch calls across requests and deployments, unless you explicitly opt out. This is what allows a data-fetching page to remain fast without hitting the origin every time.

app/page.tsx

async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'force-cache', // default behavior for most fetches
  });
  return res.json();
}

7. Full Route Cache đŸ–ŧī¸

At build time (or after revalidation), Next.js can cache the fully rendered RSC payload and HTML for a route — the Full Route Cache — so subsequent requests skip rendering entirely.

  • Applies to statically rendered routes.
  • Automatically invalidated when the underlying Data Cache entries it depends on are revalidated.
  • Bypassed entirely for routes marked as dynamic.

8. Router Cache 🧭

On the client, Next.js keeps an in-memory cache of previously visited route segments, making back/forward navigation and prefetched links feel instant without a server round-trip.

Information

The Router Cache lives in the browser's memory for the current session — a hard refresh clears it completely.

9. Client Cache 🌐

Beyond the Router Cache, standard browser-level HTTP caching still applies to static assets like images, scripts, and fonts, governed by normal Cache-Control response headers.

next.config.js

module.exports = {
  async headers() {
    return [
      {
        source: '/images/:path*',
        headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
      },
    ];
  },
};

10. Static Caching 🧊

A route is statically rendered when Next.js can determine its output at build time (or on first request) and reuse it for every subsequent visitor — the fastest possible path.

app/blog/page.tsx

export default async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts').then((r) => r.json());
  return <PostList posts={posts} />;
}

11. Dynamic Caching 🔄

A route becomes dynamic — rendered fresh on every request — when it reads request-specific data such as cookies, headers, or search params, or when a fetch explicitly opts out of caching.

app/dashboard/page.tsx

import { cookies } from 'next/headers';

export default async function Dashboard() {
  const session = cookies().get('session'); // forces dynamic rendering
  return <div>Welcome back!</div>;
}

12. Cache Control đŸŽ›ī¸

You control fetch-level caching with the cache option, and time-based revalidation with next.revalidate.

lib/data.ts

// Never cache — always fetch fresh
fetch(url, { cache: 'no-store' });

// Cache indefinitely until manually revalidated
fetch(url, { cache: 'force-cache' });

// Cache, but revalidate after 60 seconds
fetch(url, { next: { revalidate: 60 } });

13. Cache Tags đŸˇī¸

Tags let you label a cached fetch entry so it can be invalidated by name later, rather than by path — useful when one piece of data appears on multiple pages.

lib/data.ts

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

14. cache()

The cache() function from React memoizes the return value of an arbitrary function for the duration of a single render — useful for deduping non-fetch work like database calls.

lib/data.ts

import { cache } from 'react';

export const getUser = cache(async (id: string) => {
  return db.user.findUnique({ where: { id } });
});

15. unstable_cache

unstable_cache extends caching to non-fetch data sources — like direct database queries — giving them the same tag- and time-based revalidation as the Data Cache.

lib/data.ts

import { unstable_cache } from 'next/cache';

export const getCachedPosts = unstable_cache(
  async () => db.post.findMany(),
  ['posts-list'],
  { tags: ['posts'], revalidate: 3600 }
);

Caution

As the name suggests, unstable_cache's API has changed between versions — pin your Next.js version and check the changelog before upgrading.

16. revalidate âąī¸

The revalidate option — whether on a fetch call, a route segment config export, or unstable_cache — defines how long, in seconds, a cache entry stays fresh before Next.js fetches new data.

app/page.tsx

export const revalidate = 3600; // revalidate this route every hour

17. Time-Based Revalidation âŗ

This is Incremental Static Regeneration in practice: a cached page keeps serving instantly, and Next.js regenerates it in the background once the revalidate window has passed.

  1. A request arrives for a page whose cache has expired.
  2. Next.js serves the stale cached version immediately.
  3. In the background, the page is re-rendered and the cache updated.
  4. The next request receives the fresh version.

18. On-Demand Revalidation 🔔

Rather than waiting for a timer, you can trigger revalidation immediately — typically from a webhook fired when content changes in a CMS.

app/api/webhook/route.ts

import { revalidateTag } from 'next/cache';

export async function POST(request: Request) {
  const { tag } = await request.json();
  revalidateTag(tag);
  return Response.json({ revalidated: true });
}

19. revalidatePath

revalidatePath clears the Full Route Cache and Data Cache for a specific path, forcing the next visit to that route to render fresh.

app/actions.ts

'use server';
import { revalidatePath } from 'next/cache';

export async function publishPost(id: string) {
  await db.post.update({ where: { id }, data: { published: true } });
  revalidatePath('/blog');
}

20. revalidateTag

revalidateTag invalidates every cached fetch entry sharing a given tag, regardless of which route it was fetched from — more precise than invalidating an entire path.

app/actions.ts

'use server';
import { revalidateTag } from 'next/cache';

export async function updateProduct(id: string) {
  await db.product.update({ where: { id }, data: { inStock: false } });
  revalidateTag(`product-${id}`);
}

21. updateTag

updateTag is a newer, more surgical revalidation primitive: instead of clearing an entire tag's cache, it lets you update the cached value in place immediately after a mutation, avoiding a stale read on the very next request.

app/actions.ts

'use server';
import { updateTag } from 'next/cache';

export async function toggleLike(postId: string) {
  await db.post.update({ where: { id: postId }, data: { likes: { increment: 1 } } });
  updateTag(`post-${postId}`);
}

Reference

Newer cache primitives like updateTag have shipped as experimental APIs in recent Next.js releases — check the official documentation for current stability and availability in your version.

22. Cache Invalidation 🧹

There's a well-known saying in computer science:

>>There are only two hard things in Computer Science: cache invalidation and naming things.
Next.js gives you three main invalidation tools — revalidatePath, revalidateTag, and time-based revalidate — and choosing the right granularity is the real skill.

23. Dynamic Rendering Triggers đŸ”Ĩ

Certain APIs automatically force a route out of static rendering and into dynamic, per-request rendering. Knowing these prevents accidentally losing the Full Route Cache.

  • Reading cookies() or headers().
  • Accessing searchParams in a Server Component.
  • A fetch call using cache: 'no-store'.
  • Setting export const dynamic = 'force-dynamic'.

24. Fetch Cache Options 🔧

OptionBehavior
cache: 'force-cache'Reuse cached data indefinitely (default for most fetches)
cache: 'no-store'Never cache; always fetch fresh
next: { revalidate: N }Cache, but refresh after N seconds
next: { tags: [...] }Label the cache entry for tag-based invalidation

25. Database Caching đŸ—„ī¸

For direct database access (no fetch involved), wrap your query functions with unstable_cache or React's cache() to get equivalent caching and tag-based invalidation.

lib/db.ts

import { unstable_cache } from 'next/cache';

export const getActiveUsers = unstable_cache(
  () => db.user.findMany({ where: { active: true } }),
  ['active-users'],
  { tags: ['users'], revalidate: 300 }
);

26. CDN Caching 🌍

When deployed behind a CDN, statically rendered pages and cached responses can also be cached at the edge, serving requests without ever reaching your origin server.

Information

CDN behavior depends heavily on your hosting provider — consult its documentation for how it interprets Next.js's cache headers and revalidation signals.

27. Browser Caching đŸ–Ĩī¸

Static assets served from public/ and build output are typically sent with long-lived, immutable Cache-Control headers, since their filenames are content-hashed and change whenever the content does.

next.config.js

module.exports = {
  async headers() {
    return [
      { source: '/_next/static/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }] },
    ];
  },
};

28. Performance Optimization đŸŽī¸

  • Prefer tag-based revalidation over broad path revalidation for precise invalidation.
  • Keep as much of your route static as possible; isolate dynamic data behind smaller, targeted components.
  • Use Suspense boundaries to stream dynamic sections without blocking the whole page.
  • Set realistic revalidate windows — too short defeats the purpose of caching, too long risks stale content.

29. Debugging Cache Issues 🐞

  1. Check whether the route is statically or dynamically rendered in the build output logs.
  2. Verify the cache and next.revalidate options on every relevant fetch call.
  3. Confirm that any dynamic API usage (cookies(), headers()) isn't unintentionally opting a route out of caching.
  4. Trace tag names end-to-end — a typo in a tag string silently breaks revalidateTag.

Tip

Add temporary console.log statements around data-fetching functions to confirm whether a code path actually re-executes on a given request.

30. Best Practices ✅

  1. Tag data at the smallest meaningful granularity (e.g. per-item, not just per-collection).
  2. Prefer on-demand revalidation over very short time-based windows when data changes are event-driven.
  3. Keep dynamic data access scoped to small, isolated components rather than whole pages.
  4. Document which cache layer each revalidation call actually affects — it saves confusion later.

31. Common Mistakes đŸšĢ

Common Caching Mistakes
Assuming fetch is always cached, even after adding cookies() elsewhere on the page
Using revalidatePath when a more targeted revalidateTag would do
Stale UI after mutations
Setting revalidate far too low, negating any caching benefit
Forgetting to call any revalidation function inside a Server Action
Tagging data on write but never on read, so the tag never matches

32. Frequently Asked Questions ❓

Yes — GET Route Handlers follow the same fetch-level caching rules; other HTTP methods are never cached by default.

revalidatePath clears everything cached for a specific route path, while revalidateTag clears every cache entry sharing a tag, regardless of which route fetched it.

By default, Next.js disables most persistent caching in development mode so you always see your latest changes reflected immediately.

33. Summary 📚