Type something to search...
Caching and Revalidating (Previous Model)

Caching and Revalidating (Previous Model)

Next.js 16 introduced Cache Components, a new caching model built around the use cache directive that makes caching an explicit, opt-in decision rather than an implicit consequence of how you write your data-fetching code. If you've enabled the cacheComponents flag in your next.config.js, that new model is the one you want to read about.

This article is for everyone else — the very large number of App Router projects that predate Cache Components, or that simply haven't turned the flag on yet. In this "previous model," caching isn't something you opt into with a directive. It's an implicit behavior baked into fetch, layered with route segment config exports, and patched around with helper functions like unstable_cache for anything that isn't fetch. It works, and it's still fully supported, but it earns its reputation for being confusing because so much of its behavior depends on where in your component tree a piece of code runs relative to a "Request-time API" like cookies() or headers().

Understanding this model matters even if you plan to migrate to Cache Components eventually, because you can't appreciate what Cache Components fixes until you've felt the seams in the system it replaces. And if you're maintaining an existing production app, ripping out working caching logic to chase a new flag is rarely the first thing on your list. This guide walks through every mechanism in the previous model — caching fetch requests, unstable_cache, route segment config, time-based and on-demand revalidation, request deduplication, and preloading — with the context the docs assume you already have.

The Mental Model: Static by Default, Dynamic on Contact

Before touching any API, it helps to understand the governing idea behind this caching model: a route is prerendered as far as it can be, until it hits something that can't be known ahead of time.

Next.js walks through your route's layouts and pages at build time, executing them like a script. As long as everything stays "static" — no reading of cookies, no reading of the incoming URL's search params, no fetch call explicitly marked as dynamic — Next.js keeps generating HTML and caching the result. The moment it hits a Request-time API (cookies(), headers(), searchParams, or a fetch call using cache: 'no-store'), everything after that point in the render becomes dynamic and is computed fresh on every request, while everything discovered before it remains static and cached.

This is why the docs keep referring to whether a fetch call happens "before" or "after" a Request-time API — it isn't a stylistic detail, it's the entire mechanism the automatic defaults are built on. Once you internalize this, the rest of the model — dynamic, fetchCache, and friends — reads less like a pile of unrelated config flags and more like a set of ways to override this default split-at-the-seam behavior.

Caching fetch Requests

The most common way data enters a Next.js app is through fetch, and in this model, fetch calls are not cached by default. That surprises a lot of people coming from the Pages Router, where getStaticProps cached everything implicitly. In the App Router's previous model, you cache a specific fetch call explicitly by passing cache: 'force-cache':

// app/page.tsx
export default async function Page() {
  const data = await fetch("https://api.example.com/products", {
    cache: "force-cache",
  });
}

'force-cache' tells Next.js: treat this response as static, cache it, and reuse it across requests until something explicitly invalidates it. This is the closest equivalent to getStaticProps — the request runs once at build time (or on first access, depending on how the route is otherwise configured) and its result is reused.

The opposite is cache: 'no-store', which tells Next.js never to cache this particular call and to always hit the network fresh. If a route contains even one no-store fetch that runs during the render, that fetch — and anything discovered after it — makes the surrounding route dynamic.

A subtlety worth internalizing: not specifying a cache option isn't neutral. Depending on the route segment's fetchCache setting (more on that below) and whether the fetch happens before or after a Request-time API, an unmarked fetch call will implicitly behave like either force-cache or no-store. If you've ever wondered why a fetch call you didn't configure at all suddenly started behaving differently after you added a cookies() call somewhere upstream in the same route, this is the reason — you tripped the "before vs. after a Request-time API" boundary without realizing it.

unstable_cache for Non-fetch Functions

Most real applications don't fetch everything over HTTP. You might be querying Postgres through Prisma or Drizzle, reading from Redis, or calling an internal RPC client. None of that goes through fetch, so none of it gets the implicit caching behavior described above — by default it runs on every request, every time.

unstable_cache is the escape hatch. Despite the intimidating name (the unstable_ prefix in Next.js generally means "the API works, but the shape might still change in a future major version" — it doesn't mean "flaky"), it's a well-established, widely used function for wrapping arbitrary async functions in the same caching machinery fetch uses under the hood.

// app/lib/data.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";

export const getCachedUser = unstable_cache(
  async (id: string) => {
    return db
      .select()
      .from(users)
      .where(eq(users.id, id))
      .then((res) => res[0]);
  },
  ["user"], // cache key prefix
  {
    tags: ["user"],
    revalidate: 3600,
  },
);

Three things to understand about the signature that the reference docs state tersely and are worth spelling out:

  1. The first argument is the function you're caching. It can take arguments — those arguments become part of the cache key automatically, so getCachedUser("123") and getCachedUser("456") are cached separately without you having to do anything.
  2. The second argument is a key prefix, not the full key. Next.js appends a hash derived from the function's arguments and closed-over variables to this prefix to build the real cache key. In practice, keep this prefix stable and descriptive — it's mostly there to namespace your cache entries so you can reason about them, and to give you something predictable to look at in build output or cache debugging tools.
  3. tags and revalidate in the third argument work exactly like their fetch equivalents. A tags array lets you invalidate this specific cached result on demand later with revalidateTag. A revalidate number sets a time-based expiry in seconds.

A mistake I see constantly: wrapping a function that reads request-specific data (like the current user's session, pulled from cookies()) inside unstable_cache without passing that session data in as an argument. Because the cache key is derived from the function's arguments, not from ambient state it happens to read internally, you can accidentally serve User A's cached data to User B if the function reaches outside its own arguments to decide what to return. If a cached function's output depends on anything beyond what you pass into it, that dependency needs to become an explicit parameter, or you need to exclude it from caching entirely.

Route Segment Config

Route segment config is a set of variable exports you place directly in a layout, page, or route file to change how Next.js treats that whole segment, rather than fine-tuning one fetch call at a time.

dynamic

This is the blunt instrument — it decides whether an entire route is static, dynamic, or something in between.

// layout.tsx | page.tsx | route.ts
export const dynamic = "auto";
// 'auto' | 'force-dynamic' | 'error' | 'force-static'
  • 'auto' (the default) leaves Next.js to figure out static vs. dynamic per the "static until it hits a Request-time API" rule described earlier. Most routes should stay on 'auto' — it's the option that lets Next.js cache the most it safely can without you having to think about it.
  • 'force-dynamic' turns off static optimization entirely for the segment. Every request re-renders it from scratch, as if every fetch used no-store. Reach for this on routes that are inherently per-request — an admin dashboard behind auth that must reflect live state, for example — rather than trying to force dynamism by sprinkling no-store on individual fetches.
  • 'error' is the strict opposite: it forces the segment to be fully static and throws a build error if anything inside it tries to use a Request-time API or an uncached fetch. This is a genuinely useful guardrail for marketing pages or documentation sites where an accidental dynamic dependency creeping in later (someone adds a cookies() call for an A/B test, say) should fail the build loudly instead of silently making the page slower.
  • 'force-static' is a stranger option: it forces the page to render statically by making cookies(), headers(), and useSearchParams() all return empty values, rather than throwing. That's a meaningfully different behavior from 'error' — code that reads these APIs doesn't break, it just silently gets nothing back. It's designed for pages you want static even though some component deep in the tree references a Request-time API defensively; use it deliberately, not as a way to silence an error you don't understand yet.

fetchCache

This is the option the docs themselves flag as advanced, and I'd underline that. fetchCache overrides the default cache behavior for every fetch call in a segment that doesn't specify its own cache option explicitly.

export const fetchCache = "auto";
// 'auto' | 'default-cache' | 'only-cache'
// 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'

You'll rarely need this. The realistic use case is a large, legacy route where dozens of fetch calls scattered across many files don't set an explicit cache option, and you need to shift the whole segment's default behavior in one place rather than editing every call site. The 'only-*' variants are particularly useful as a lint-like safety net — 'only-cache' will throw a build error if it finds even one fetch explicitly using no-store, which is a good way to guarantee a segment stays fully static even as multiple people touch the code over time.

One rule buried in the docs that's easy to miss: these options have to agree across every layout and page in a single route. You can't set 'force-cache' in a layout and 'force-no-store' in a child page of that same route — Next.js needs the whole route to resolve to one coherent caching story, and force-* options always win over only-* options when they conflict.

Time-Based Revalidation

Time-based revalidation is what most people mean when they say "ISR" in casual conversation, even though ISR technically refers to the broader pattern (see the separate Incremental Static Regeneration guide for that). The mechanism itself is simple: cache a result, and automatically refetch it after N seconds have passed.

For fetch, this is the next.revalidate option:

// app/page.tsx
export default async function Page() {
  const data = await fetch("https://api.example.com/products", {
    next: { revalidate: 3600 },
  });
}

For unstable_cache, it's the revalidate field in the options object shown earlier.

You can also set a default at the route segment level:

// layout.tsx | page.tsx | route.ts
export const revalidate = false;
// false | 0 | number
  • false (the default) means: cache indefinitely, but let individual fetches override this with their own cache or revalidate settings.
  • 0 forces the segment to always be dynamically rendered, even if nothing inside it reads a Request-time API.
  • A number sets the segment's default revalidation window in seconds.

The detail worth calling out because it trips people up in code review: the lowest revalidate value across an entire route wins. If a parent layout sets revalidate = 3600 and a child page sets revalidate = 60, the whole route — including the parent layout's data — revalidates every 60 seconds. This exists so a child can never be staler than its parent claims, but it means you can't casually give different pieces of the same route wildly different freshness guarantees just by setting different numbers in different files; the strictest number always propagates upward through the tree.

Also worth remembering: the value has to be a literal, statically analyzable number. revalidate = 600 works; revalidate = 60 * 10 does not, because Next.js needs to read this value without executing your code. This one has bitten me more than once when refactoring "magic numbers" into named constants with arithmetic — the segment config exports are one place where you have to leave the arithmetic inline or precompute the literal.

On-Demand Revalidation

Time-based revalidation is good for content that goes stale on a predictable schedule. It's the wrong tool for "the editor just clicked publish and the cache needs to update right now." That's what on-demand revalidation — revalidateTag and revalidatePath — is for.

Tagging Cached Data

You attach tags to a fetch call (or to an unstable_cache-wrapped function) so you have something to target for invalidation later:

// app/lib/data.ts
export async function getUserById(id: string) {
  const data = await fetch(`https://api.example.com/users/${id}`, {
    next: { tags: ["user"] },
  });
}

revalidateTag

From inside a Server Action or a Route Handler — anywhere with access to server-side execution triggered by a mutation — call revalidateTag with the same string:

// app/lib/actions.ts
"use server";

import { revalidateTag } from "next/cache";

export async function updateUser(id: string) {
  // ...mutate the user in your database
  revalidateTag("user");
}

Every cached entry tagged "user", anywhere in the app, gets invalidated. This is the pattern you want for CMS-driven content: tag a fetch by the entity it represents (post-42, category-shoes, whatever granularity makes sense for your data), and revalidate that exact tag from a webhook handler when the CMS reports a change.

revalidatePath

If tagging feels like overkill for a given case, revalidatePath invalidates everything cached for a specific route:

import { revalidatePath } from "next/cache";

export async function updateUser(id: string) {
  revalidatePath("/profile");
}

The trade-off is coarser granularity. revalidatePath('/profile') invalidates the entire /profile route's cache, regardless of which specific piece of data actually changed. Tags let multiple, unrelated pages that all happen to reference "user 42" get invalidated together without you having to know or list every path that shows that user's data. Prefer tags as your default; reach for revalidatePath when a change genuinely affects an entire page wholesale (you republished the whole page's content, not one item on it).

Deduplicating Requests

fetch is automatically memoized within a single render — if five different components on the same page call fetch with identical arguments, Next.js only makes the network request once and shares the result. This is separate from caching between requests; it's about not making the same request five times during one page render just because five components independently need the same data.

If you're not going through fetch — again, direct ORM or database calls — you don't get this deduplication for free. React's cache function gives it back to you:

// app/lib/data.ts
import { cache } from "react";
import { db, posts, eq } from "@/lib/db";

export const getPost = cache(async (id: string) => {
  const post = await db.query.posts.findFirst({
    where: eq(posts.id, parseInt(id)),
  });
  return post;
});

Note this is a different tool solving a different problem than unstable_cache. React's cache deduplicates within a single render pass and doesn't persist anything between requests — call getPost("42") from a layout and again from a nested page in the same request, and the database only gets hit once, but the very next request hits it again fresh. unstable_cache, by contrast, persists results across requests until its revalidate window or a tag invalidation clears it. They compose well together — you can wrap a database call in unstable_cache for cross-request caching, and separately rely on React's memoization of fetch-like patterns, or use cache directly around functions that read from that cached layer, to avoid redundant calls within one render.

Preloading Data

Data fetching that starts as late as the component that needs it is data fetching that makes your users wait longer than necessary. If a component two levels lower in the tree needs data that has no dependency on anything happening above it, you can kick off that fetch earlier — before some other blocking await finishes — using a "preload" pattern built from server-only and React's cache:

// utils/get-item.ts
import { cache } from "react";
import "server-only";

export const getItem = cache(async (id: string) => {
  // ...expensive lookup
});

export const preload = (id: string) => {
  void getItem(id);
};
// app/item/[id]/page.tsx
import { getItem, preload, checkIsAvailable } from "@/lib/data";

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // Kick off the fetch immediately — don't await it yet.
  preload(id);

  // Do something else that takes time.
  const isAvailable = await checkIsAvailable();

  return isAvailable ? <Item id={id} /> : null;
}

async function Item({ id }: { id: string }) {
  const result = await getItem(id); // Already in flight, or already resolved.
  // ...
}

The trick is the bare void getItem(id) call — it starts the async function and its underlying request, but doesn't block on the result. Because getItem is wrapped in React's cache, the later await getItem(id) inside Item doesn't trigger a second network call — it reads the same in-flight promise (or its resolved value) that preload already kicked off. This shaves the fetch's latency out of the critical path whenever there's other independent work — another await, another data source — happening in parallel above it in the tree. The server-only import is there for safety: it guarantees this file throws a build error if a Client Component ever tries to import it, since this preload pattern only makes sense on the server.

What This Model Doesn't Give You

It's worth being honest about where the previous model shows its age, since that's the whole reason Cache Components exists:

  • Caching is implicit and positional. Whether a piece of code is "static" or "dynamic" depends on where it sits relative to a Request-time API call elsewhere in the same route — a detail that isn't visible at the call site itself. Move a cookies() call earlier in a shared layout, and a fetch several files away silently starts behaving differently.
  • There's no per-component override. Route segment config applies to the whole layout or page; you can't declare "this one component's data should be cached independently of whatever else happens on this page" without reaching for unstable_cache as a workaround.
  • Route-wide constraints compound. The revalidate propagation rule (lowest value wins across the whole route) and the fetchCache agreement rule (children can't disagree with force-* parents) both mean a caching decision made in one file can quietly change the behavior of a sibling file you never touched.

None of this makes the previous model wrong to use today. Most production Next.js apps run on it, it's well-documented, and it's not being removed. But if you find yourself constantly debugging why a particular route rendered statically or dynamically, that's usually the signal that Cache Components' explicit use cache directive — where caching is a decision you make at the function or component level, not an emergent property of render order — is worth evaluating for that part of your app.

Key Takeaways

MechanismUse it for
fetch(url, { cache: 'force-cache' })Caching an individual fetch call indefinitely
fetch(url, { next: { revalidate: N } })Time-based revalidation for one fetch call
unstable_cache(fn, keyParts, options)Caching non-fetch data sources (databases, ORMs, RPC clients)
export const dynamic = '...'Forcing an entire route segment static, dynamic, or erroring on dynamic usage
export const fetchCache = '...'Overriding the default cache behavior for unmarked fetch calls in a segment
export const revalidate = NSetting a segment-wide default revalidation window
revalidateTag('tag')Invalidating cached data by tag, from a Server Action or Route Handler
revalidatePath('/path')Invalidating everything cached for a specific route
React's cache()Deduplicating identical calls within a single render pass
preload() patternStarting a fetch early, before a later blocking await needs its result

Learn these ten mechanisms and you have the entire previous caching model — no more, no less. Everything else you'll encounter in an existing App Router codebase is some combination of these building blocks.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper