Hazem Azzam

All posts
Writing

Caching in Next.js: A Practical Guide for the App Router

Next.js layers several caches—client, server, and on-demand invalidation. This post explains how the App Router cache components model works, when to use cacheLife versus cacheTag, and how to avoid stale data in production.

4 min read
nextjscachingapp-routerreactweb-performance

Why caching matters in Next.js

Every page in a Next.js App Router application is built from Server Components, data fetches, and optional Client Components. Without caching, every navigation would re-run every fetch on the server. With caching, you trade a bit of complexity for faster responses, lower API load, and better perceived performance.

The mental model that helps most teams is simple: there is more than one cache. The framework can cache on the server, on the edge, and in the browser. Each layer has different rules about when data is considered fresh, when it can be served while refreshing in the background, and when you must throw it away on purpose.

This guide focuses on the cache components model in modern Next.js (with cacheComponents: true), which replaces much of the older fetch(..., { next: { revalidate } }) mental model for app code you control directly.

The building blocks

"use cache" — mark a function or component as cacheable

When you add the "use cache" directive at the top of an async function or component, Next.js treats its return value as a cache entry. The next time the same inputs are requested, the framework can reuse the stored result instead of re-executing the function.

export async function getStoreConfig(context: RequestContext) {
  "use cache";
  return storeConfigRepository.getStoreConfig(context);
}

This is the foundation. Everything else—lifetime and invalidation—plugs into entries created this way.

cacheLife — how long an entry stays useful

Tags do not set TTL. Lifetime is configured with cacheLife(), either inline or via a named profile in next.config.ts.

A profile has three durations (all in seconds):

PropertyRole
staleHow long the client may reuse data without checking the server
revalidateAfter this age, the server may serve cached data once and refresh in the background (ISR-style)
expireAfter long idle time, the next request must wait for fresh data (expire must be greater than revalidate)

Example config:

// next.config.ts
const nextConfig = {
  cacheComponents: true,
  cacheLife: {
    storeConfig: {
      stale: 60 * 5,
      revalidate: 60 * 60,
      expire: 60 * 60 * 24,
    },
  },
};

Use it in the cached function:

import { cacheLife, cacheTag } from "next/cache";

export async function getStoreConfig(context: RequestContext) {
  "use cache";
  cacheTag("store-config");
  cacheLife("storeConfig");
  return storeConfigRepository.getStoreConfig(context);
}

Preset names like "hours", "days", and "max" ship with Next.js if you do not need custom profiles. If you omit cacheLife, the default profile applies.

cacheTag — label entries for on-demand invalidation

cacheTag("store-config") does not control how long data lives. It attaches a label so you can invalidate related entries when something changes in the real world—for example, after an admin updates store settings.

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

export async function afterStoreSettingsSaved() {
  revalidateTag("store-config", "max");
}

The second argument to revalidateTag is a revalidation profile (often "max" for stale-while-revalidate on the next visit). That is separate from the initial cacheLife on the cached function.

Rule of thumb: cacheLife = time-based freshness; cacheTag + revalidateTag = event-based freshness.

Client cache vs server cache

  • stale mainly affects the client router: for a few minutes, navigations can feel instant because the browser does not need to revalidate with the server on every click.
  • revalidate and expire mainly affect the server-side cache: how often background regeneration runs and when an entry is too old to serve at all.

When you call revalidateTag from a Server Action, the entire client cache can clear immediately, bypassing stale—useful when you know data changed and cannot wait for timers.

What should stay uncached?

Not everything belongs in "use cache".

  • Per-user data (cart, profile, order history) must use "use server" reads without cross-user caching, or you risk leaking one user's data to another.
  • Mutations should return structured results (for example ActionResult) instead of throwing, so production clients see real error messages.
  • Highly dynamic data (live inventory, stock tickers) may need short profiles like "seconds" or no cache at all—and short-lived caches can become "dynamic holes" during prerendering, which is intentional.

Project conventions often look like this:

PatternDirectiveCaching
Catalog, store config, categories"use cache" + cacheLife + optional cacheTagYes
Cart, auth session reads"use server"No shared cache
Form submissions"use server" + ActionResultN/A

Nested caches

If a Server Component with "use cache" renders another cached child, the outer function should usually set an explicit cacheLife. Without it, inner shorter lifetimes can shrink the outer cache in surprising ways. Explicit profiles make behavior inspectable: you read one function and know its policy.

Older patterns you may still see

fetch with next.revalidate and tags

For third-party HTTP calls, fetch(url, { next: { revalidate: 3600, tags: ["posts"] } }) still integrates with tag invalidation. App-owned logic increasingly prefers "use cache" plus repository calls so caching stays in your domain layer, not scattered at every fetch.

unstable_cache

The older unstable_cache wrapper is largely superseded by "use cache" for new code. Migrate when you touch those call sites.

revalidatePath

revalidatePath("/products") invalidates a route segment. revalidateTag invalidates all entries with a tag across pages. Use tags when the same data appears in many places (header store config, layout, checkout).

A small end-to-end example

Imagine store configuration loaded on every page:

  1. Enable cacheComponents: true in next.config.ts.
  2. Define a storeConfig profile with sensible stale / revalidate / expire.
  3. In getStoreConfig, use "use cache", cacheTag("store-config"), and cacheLife("storeConfig").
  4. When the dashboard saves settings, call revalidateTag("store-config", "max").

Users keep fast navigations from stale, the server avoids hammering your API from revalidate, and admins still get fresh config after a deliberate invalidation—without waiting 24 hours for expire.

Common mistakes

  1. Expecting cacheTag to set expiration — it only labels entries; use cacheLife.
  2. Caching user-specific reads — always scope per-request data outside shared cache.
  3. Forgetting to restart dev after changing next.config.ts cache profiles.
  4. Setting expirerevalidate — Next.js validates this and will error.
  5. Skipping explicit cacheLife on outer caches when nesting short-lived inner caches — can cause prerender errors or unintended short TTLs.

Closing thoughts

Next.js caching is not one switch. It is layers (client vs server) and strategies (time-based cacheLife vs event-based revalidateTag). Name your profiles in config, tag stable resources you may need to purge, and keep authenticated paths out of shared cache entries.

Once that split is clear, tuning performance stops feeling magical—and your storefront can stay fast without showing yesterday's store hours after a real update.


Rate this post

All fields are optional. Just stars is fine.

No ratings yet