Overview

Next.js 15 has four cache layers and they cache less by default than Next.js 14 did. Each layer answers a different question: deduplicating one render, persisting fetched data across requests, persisting the rendered HTML, and persisting the client-side navigation state. Knowing which layer to reach for is the difference between a fast app and a stale one.

Know the four layers

The layers stack from innermost to outermost:

  • Request Memoization: React cache() deduplicates a function call within one server render. Two components that ask for getUser(id) hit the source once.
  • Data Cache: fetch() results and unstable_cache() calls persist across requests. Lives on disk (Vercel: regional). Survives deploys until invalidated.
  • Full Route Cache: rendered HTML and RSC payload for a static route, persisted at build time and refreshed by ISR or revalidatePath.
  • Router Cache: a per-session in-memory cache in the browser that holds RSC payloads for routes the user has visited. Short-lived; flushed by revalidate/revalidateTag and by navigation.

Reach for the innermost cache that answers your question. Most data lives in the Data Cache; layout chrome lives in the Full Route Cache.

Memoize per-render fetches with React cache

When two server components in the same render need the same record, wrap the loader in cache() so the underlying call runs once.

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

cache is per-render. The next request gets a fresh memo. Use it for database calls that fetch cannot wrap. The fetch API has its own dedupe within a render; no extra work needed.

Cache fetch deliberately, opt out when you must

Next.js 15 does not cache fetch by default. Opt in with next.revalidate or next.tags. Opt out with cache: "no-store" (or by reading dynamic functions like cookies()).

// Static, refreshed every 60s.
await fetch(url, { next: { revalidate: 60, tags: ["posts"] } });
 
// Dynamic, never cached.
await fetch(url, { cache: "no-store" });
 
// Cached forever until revalidated by tag.
await fetch(url, { next: { tags: ["posts"] } });

revalidate: 0 is the same as no-store. revalidate: false (or a large number) caches indefinitely. Tag every cached fetch you might invalidate later; the tag is what revalidateTag looks for.

Use unstable_cache for non-fetch work

Database calls, third-party SDK calls, and heavy computations do not flow through fetch. Wrap them with unstable_cache to persist results in the Data Cache.

import { unstable_cache } from "next/cache";
 
export const getTopPosts = unstable_cache(
  async () => db.post.findMany({ orderBy: { score: "desc" }, take: 10 }),
  ["top-posts"],         // cache key parts
  { revalidate: 300, tags: ["posts"] }
);

The key array is part of the cache key; any input that changes the result must be in it. Pass the user id, the locale, the query parameters. Two callers with different keys get separate entries.

Invalidate by tag for collections, by path for pages

revalidateTag("posts") invalidates every cache entry tagged "posts", across every route. revalidatePath("/posts/[slug]", "page") invalidates the Full Route Cache and the Router Cache for the matching segment.

"use server";
import { revalidatePath, revalidateTag } from "next/cache";
 
export async function createPost(formData: FormData) {
  await db.post.create({ data: parse(formData) });
  revalidateTag("posts");          // any listing, any layout
  revalidatePath("/posts");        // the index page
}

Tag for collections (a “posts” feed shown in three places). Path for a single page that changed. Mixing is fine; document which strategy each resource uses.

Opt out of caching with dynamic functions

Calling cookies(), headers(), draftMode(), or searchParams inside a server component opts the whole route segment out of static rendering. The Full Route Cache no longer applies; data caching still works fetch-by-fetch.

// This page renders per-request because it reads cookies.
import { cookies } from "next/headers";
export default async function Account() {
  const session = await getSession(await cookies());
  return <Profile user={session.user} />;
}

Force dynamic rendering explicitly with export const dynamic = "force-dynamic". Force static with export const dynamic = "force-static" and the build fails if the route uses dynamic functions. Pick the constraint that matches the route’s data freshness.

Control the Router Cache with staleTimes

The Router Cache holds RSC payloads for routes the user already visited so a back navigation paints from memory. In Next.js 15 the dynamic stale time is 0 by default; pages refetch on revisit. Tune the budget per project:

// next.config.ts
export default {
  experimental: {
    staleTimes: { dynamic: 30, static: 180 },
  },
};

Use a higher dynamic stale time for read-heavy dashboards. Keep it at 0 for apps where the user expects fresh data after every action; server actions already invalidate the matching entries when they call revalidatePath or revalidateTag.