Caching in Next.js 15 and 16: Revalidation Patterns That Stop Stale and Over-Fetched Data

#Next.js 15 caching revalidation

Founder & Lead Developer

Expert in software development and legacy code optimization

LinkedIn

Short answer: Since Next.js 15, nothing is cached unless you ask for it: fetch defaults to no-store, and you opt in with next: { revalidate } for time-based freshness or with tags plus revalidateTag for on-demand invalidation. Next.js 16 keeps that model and adds Cache Components, where you cache functions and components with the 'use cache' directive, set lifetimes with cacheLife, tag with cacheTag, and invalidate with updateTag (read-your-own-writes in Server Actions) or revalidateTag(tag, 'max') (stale-while-revalidate). The patterns below work on both versions, with the Next.js 16 differences called out where they matter.

Next.js 15 caching revalidation changed the rules in ways that still surprise teams months after they migrate. The most visible shift: fetch calls no longer cache by default. In Next.js 13 and 14, every fetch inside a Server Component was cached indefinitely unless you explicitly opted out. In Next.js 15, the opposite is true -- every fetch is uncached by default, and you opt into caching deliberately.

That change removed a large class of accidental over-caching bugs. It also introduced a new class: teams that rely on the old defaults and wonder why their Next.js 15 apps hammer APIs on every request. Understanding what changed -- and the revalidation patterns that replace the old implicit behavior and stop stale and over-fetched data -- is what this post covers.

What Changed in Next.js 15 Caching

Before getting into patterns, it helps to be precise about the four caching layers Next.js manages and how 15 adjusted each one.

Request Memoization is still active in 15. Identical fetch calls with identical URLs and options within a single render tree are deduplicated automatically. This is an in-memory, per-request optimization -- not a persistent cache. It disappears after the request completes.

Data Cache is where the breaking change lives. In 14, fetch stored responses in the persistent Data Cache by default (cache: 'force-cache'). In 15, fetch defaults to cache: 'no-store', meaning no persistent caching unless you explicitly configure it. If you migrated a 14 app to 15 and saw a sudden spike in database or API calls, this is almost certainly why.

Full Route Cache (the HTML and RSC payload cache stored on the server) now opts into static rendering only for routes that have no dynamic functions at all. The behavior is similar to 14 but stricter about what counts as dynamic.

Router Cache (the client-side cache of already-visited routes) was also adjusted: the default staleTime dropped to zero for page segments in 15.0, meaning navigating back to a previously cached page now triggers a fresh server fetch by default. You can override this per layout or per page.

The practical takeaway: in Next.js 15, nothing is cached unless you ask for it. That is a safer starting point for correctness, but it requires you to be deliberate about every piece of data you want cached and how long that cache should live.

Pattern 1: Time-Based Revalidation with next.revalidate

Time-based revalidation is the simplest pattern and the right default for data that changes on a predictable schedule -- CMS content, pricing tables, feature flag configs, marketing copy.

// Revalidate at most every 3600 seconds (1 hour)
const res = await fetch('https://api.example.com/pricing', {
  next: { revalidate: 3600 },
})

This is equivalent to the old revalidate export on a page, but more granular -- you can set different TTLs on different fetches within the same route. The framework caches the response in the Data Cache and serves it until the TTL expires. On the next request after expiry, Next.js revalidates in the background (stale-while-revalidate semantics) and updates the cache with the fresh response.

A few things to keep in mind with this pattern:

The TTL is a ceiling, not a guarantee. If the server restarts or the Data Cache is cleared for any reason, the next request triggers a fresh fetch regardless of the TTL.

If you set revalidate: 0, that is semantically identical to cache: 'no-store' -- no caching at all. This is useful when you want to express "always fresh" explicitly rather than relying on the default.

For routes where every fetch has the same TTL, you can export revalidate from the route segment config instead of configuring it per-fetch:

// app/pricing/page.tsx
export const revalidate = 3600

export default async function PricingPage() {
  const pricing = await fetch('https://api.example.com/pricing').then(r => r.json())
  // ...
}

Pattern 2: On-Demand Revalidation with revalidatePath and revalidateTag

Time-based revalidation is a poor fit for data that changes based on user actions or external events -- a product catalog updated by a merchant, a blog post published by an editor, an order status changed by a webhook. For these cases, on-demand revalidation is the right tool.

revalidatePath invalidates the Full Route Cache for a specific path. After the next request hits that path, Next.js re-renders the page and updates the cache.

// app/actions/publishPost.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function publishPost(postId: string) {
  await db.posts.update({ id: postId, status: 'published' })
  revalidatePath('/blog')
  revalidatePath(`/blog/${postId}`)
}

revalidatePath takes an optional second argument -- 'page' (default) or 'layout'. Passing 'layout' invalidates all pages that share that layout, which is useful when a layout renders data that a mutation just changed (a navigation item showing item counts, for example).

revalidateTag is more precise and scales better for large apps. You tag individual fetch calls with one or more string identifiers, then invalidate by tag. This decouples cache invalidation from URL structure.

// Tagging a fetch
const product = await fetch(`https://api.example.com/products/${id}`, {
  next: { tags: ['product', `product-${id}`] },
})

// Invalidating by tag from a Server Action or Route Handler (Next.js 15)
import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: ProductUpdate) {
  await db.products.update({ id, ...data })
  revalidateTag(`product-${id}`)       // invalidates this specific product
  // revalidateTag('product')          // would invalidate all products
}

In Next.js 16 the single-argument form of revalidateTag is deprecated. It now takes a cache profile as the second argument, and the framework offers a separate function for the case where the user must see their own change immediately:

'use server'

import { revalidateTag, updateTag } from 'next/cache'

export async function updateProduct(id: string, data: ProductUpdate) {
  await db.products.update({ id, ...data })

  // The merchant who saved the form sees the new data on the next render
  updateTag(`product-${id}`)

  // Listing pages can serve the old version while they refresh in the background
  revalidateTag('product-list', 'max')
}

updateTag expires the tag so the next request waits for fresh data instead of serving the stale entry. It only works inside Server Actions. revalidateTag(tag, 'max') marks the entry stale and refreshes it in the background, which is the right behaviour for data other people see and for webhooks. Next.js 16 also adds refresh() for Server Actions, which re-renders uncached data on the page (a notification count, a status badge) without touching the cache at all.

Tag-based invalidation is particularly useful for multi-tenant or content-heavy applications where the same data (a shared component, a global config) is embedded in hundreds of routes. Invalidating by tag is a single call regardless of how many routes embed that data.

One limitation: revalidateTag only works for the Data Cache (fetch responses). It does not reach into client-side caches. If your client has a stale Router Cache entry for a page you just revalidated, the user may not see the fresh data until they navigate away and back, or until the Router Cache entry expires.

Pattern 3: Webhook-Driven Revalidation via Route Handlers

CMS platforms, e-commerce backends, and payment processors all emit webhooks when content or state changes. Wiring those webhooks directly to revalidateTag or revalidatePath is cleaner than periodic polling and gives you near-realtime cache updates without the overhead of making every route dynamic.

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret')
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
  }

  const body = await request.json()
  const { type, id } = body

  switch (type) {
    case 'product.updated':
      revalidateTag(`product-${id}`, 'max')
      break
    case 'catalog.published':
      revalidateTag('catalog', 'max')
      break
    default:
      return NextResponse.json({ message: 'Unhandled event type' }, { status: 400 })
  }

  return NextResponse.json({ revalidated: true, at: Date.now() })
}

The 'max' profile argument is the Next.js 16 signature. On Next.js 15, drop the second argument. updateTag is not an option here, because it only works in Server Actions.

Two things this handler gets right that many production implementations miss: the shared secret check (always validate that the webhook is from who it claims to be), and a meaningful response body that your webhook provider can log for debugging.

For Shopify, Contentful, Sanity, Stripe, and most other major platforms, you configure the webhook endpoint URL and the shared secret in their admin panels, then point them at this route. When a product changes in Shopify, your Next.js product pages revalidate within seconds rather than waiting for a cron job or a TTL to expire.

Pattern 4: Route-Level Cache Control for Mixed Static and Dynamic Routes

Not every route in an application falls cleanly into "always static" or "always dynamic." A product detail page might have a static header and description (change rarely, safe to cache) alongside real-time stock levels and personalized recommendations (must be fresh per request).

The right tool here is Partial Prerendering (PPR): a static shell with dynamic holes wrapped in Suspense. In Next.js 15 it was experimental and only available on canary releases, enabled per route with export const experimental_ppr = true. Next.js 16 removed that flag. PPR is now part of Cache Components, turned on once with cacheComponents: true in next.config.ts. The example below uses the Next.js 15 fetch options. On Next.js 16 the idiomatic version moves ProductHeader's data access into a 'use cache' function, covered further down, and leaves the page structure unchanged:

// app/products/[id]/page.tsx
import { Suspense } from 'react'

async function ProductHeader({ id }: { id: string }) {
  // Cached -- product description changes rarely
  const product = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 3600, tags: [`product-${id}`] },
  }).then(r => r.json())

  return <ProductDetails product={product} />
}

async function StockLevel({ id }: { id: string }) {
  // Always fresh -- stock changes constantly
  const stock = await fetch(`https://api.example.com/products/${id}/stock`, {
    cache: 'no-store',
  }).then(r => r.json())

  return <StockBadge level={stock.available} />
}

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

  return (
    <div>
      <ProductHeader id={id} />
      <Suspense fallback={<StockSkeleton />}>
        <StockLevel id={id} />
      </Suspense>
    </div>
  )
}

Note that params is a Promise. Next.js 15 introduced async params, searchParams, cookies() and headers() with a compatibility shim for synchronous access, and Next.js 16 removed the shim. Code that reads params.id directly no longer works.

With PPR enabled, ProductHeader renders into the static shell cached at the edge. StockLevel streams in at request time with a fresh fetch. The user gets instant perceived performance from the static shell and accurate data from the dynamic sections -- without needing two separate routes or a client-side fetch waterfall.

Pattern 5: Unstable Cache for Non-Fetch Data Sources

fetch caching only applies to actual fetch calls. If your data comes from a database query, an ORM, a file system read, or a third-party SDK that does not use fetch internally, none of the above patterns apply directly.

On Next.js 15, unstable_cache extends caching to arbitrary async functions:

import { unstable_cache } from 'next/cache'

const getCachedProduct = unstable_cache(
  async (id: string) => {
    return await db.products.findUnique({ where: { id } })
  },
  ['product'],  // cache key prefix
  {
    revalidate: 3600,
    tags: [`product`],
  }
)

// In your Server Component
const product = await getCachedProduct(params.id)

The cache key is derived from the prefix array plus the function arguments, so getCachedProduct('abc123') and getCachedProduct('def456') are stored separately. Tags work the same way as with fetch -- you can call revalidateTag('product') from a Server Action and this cached function's result is invalidated.

On Next.js 16 with Cache Components enabled, the replacement is a function marked with 'use cache', covered in the next section. unstable_cache keeps working there as a separate layer, so you can migrate function by function.

unstable_cache is particularly useful for teams migrating a legacy codebase to Next.js 15 App Router. If you have an existing data access layer built around an ORM or a custom query library, wrapping critical queries in unstable_cache lets you adopt App Router incrementally without rewriting all data fetching to use fetch.

Next.js 16: Cache Components and use cache

Next.js 16 (October 2025) introduced Cache Components, an opt-in caching model that replaces most of the options above with one directive. Enable it in next.config.ts:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

With the flag on, everything is dynamic by default and you mark what should be cached. The directive works on functions, components, and whole pages or layouts, and the compiler derives the cache key from the arguments:

// app/lib/products.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getProduct(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('product', `product-${id}`)

  return db.products.findUnique({ where: { id } })
}

This one function replaces three older mechanisms. Fetches and ORM calls inside the scope are cached the same way, so the fetch-versus-database split from Pattern 5 disappears. cacheLife replaces next.revalidate and the route-level revalidate export, with built-in profiles ('seconds', 'minutes', 'hours', 'days', 'weeks', 'max') or custom profiles defined in next.config.ts. cacheTag replaces next.tags, and invalidation works through updateTag and revalidateTag as shown in Pattern 2.

A few differences matter in production:

Route segment configs stop working. With cacheComponents enabled, routes that still export dynamic, revalidate or fetchCache fail with an error. force-dynamic is simply unnecessary, revalidate becomes cacheLife, and force-static becomes 'use cache' with cacheLife('max'). export const runtime = 'edge' is not supported either, because Cache Components requires the Node.js runtime.

Storage is different. The old fetch Data Cache and unstable_cache persisted entries across deployments and serverless instances. 'use cache' defaults to in-memory storage that is scoped to one deployment and lost when an instance shuts down. On a single long-running Node.js server that is often fine. On serverless platforms or multiple instances, use 'use cache: remote' or configure a cache handler, and expect entries to be recomputed after every deploy.

Request data stays out of the cache. A 'use cache' scope cannot read cookies() or headers(), which is a deliberate guard against caching one user's data for everyone. Read the session outside, pass the tenant or user ID in as an argument (it becomes part of the cache key), or use 'use cache: private' for per-user results that should not be stored in a shared server cache.

Migrate incrementally. Turning the flag on in an existing app surfaces errors and dev-overlay insights for every route that cannot render a static shell. The official migration guide recommends setting export const instant = false on routes that are not ready yet, getting the app building, and then converting one route at a time. Next.js 16.3 (August 2026) builds its Instant Navigations features on the same model, and the Next.js team has said this behaviour will become the default in a future major version, so the migration is worth planning now even if you do not start it this quarter.

Common Mistakes and How to Avoid Them

Caching inside dynamic route segments without accounting for parameters. If you use unstable_cache without including the relevant parameters in the cache key, all users share the same cached result. A product page that caches inventory data without the product ID in the key will return the wrong stock level for every product except the first one to populate the cache.

Using revalidatePath when revalidateTag is more appropriate. revalidatePath invalidates HTML and RSC payload for a specific URL. If the same data appears in a shared layout, a sidebar, and three different pages, you need three revalidatePath calls. revalidateTag handles all of them with one call, as long as the fetches were tagged consistently.

Expecting immediate cache clearing from revalidatePath / revalidateTag. Both functions mark cached entries as stale. The actual re-render happens on the next request to that route, not at the moment you call revalidate. This is intentional (stale-while-revalidate semantics), but teams sometimes assume the cache is already warm and fresh by the time a redirect or navigation completes. In most cases it is -- the revalidation triggered by the Server Action fires before the action returns -- but under high concurrency or in edge environments with multiple instances, a brief window of stale data is possible.

Calling revalidateTag with one argument after upgrading to Next.js 16. The one-argument form is deprecated. Pass a profile ('max' for most cases) to keep stale-while-revalidate behaviour, or switch to updateTag in Server Actions where the user needs to see their own write.

Putting user-specific data in a shared cache. The multi-tenant version of the first mistake above. A cached function that loads "the current tenant's settings" without the tenant ID in its arguments or key serves the first tenant's data to everyone. 'use cache' makes this harder by refusing to read cookies inside the scope, but an ID read from a global or a module-level variable still slips through.

Setting revalidate: 0 to mean "cache this but check often." As mentioned earlier, revalidate: 0 means no cache at all, equivalent to cache: 'no-store'. If you want very frequent revalidation, set a small positive integer (revalidate: 30) rather than zero.

Choosing the Right Pattern

The decision tree is fairly direct once you characterize each piece of data:

Data that almost never changes (config, legal copy, navigation structure): use revalidate with a long TTL and tag it so you can bust it if a rare update happens.

Data that changes on a known schedule (daily deals, weekly reports, nightly syncs): use revalidate with a TTL that matches the update frequency.

Data that changes in response to user actions or external events: use revalidateTag or revalidatePath from Server Actions and Route Handlers. For external events, wire it to a webhook endpoint.

Data that must always be fresh for every user (personalized content, real-time feeds, auth-gated responses): use cache: 'no-store' explicitly and isolate these fetches inside Suspense boundaries if you want the rest of the page to benefit from caching.

Data from non-fetch sources (ORMs, SDKs, file reads): wrap in unstable_cache with the same TTL and tag strategy you would use for a fetch call.

On Next.js 16 with Cache Components, the same decisions map onto fewer tools: 'use cache' plus a cacheLife profile for the first two cases, cacheTag with updateTag or revalidateTag(tag, 'max') for the third, no directive (and a Suspense boundary) for the fourth, and 'use cache' again for the fifth.

Architecture Matters More Than API Knowledge

Caching in Next.js 15 and 16 is powerful, but it is also where subtle bugs tend to hide for the longest time -- stale data showing to the wrong users, cache invalidations that miss some routes, tag mismatches between where data is fetched and where mutations fire. These are not Next.js-specific problems; they are the inherent complexity of caching at any layer.

Getting the architecture right from the start -- deciding what belongs in the Data Cache, what stays dynamic, how mutations map to revalidation calls -- is a design decision that pays dividends for the life of the application. If your team is building a custom web application or an existing product in need of a technical overhaul and wants experienced eyes on your caching strategy, Wolf-Tech works directly with engineering teams on exactly these kinds of decisions. Write to hello@wolf-tech.io with a brief description of what you are building.