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

#Next.js 15 caching revalidation
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

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 -- 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
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
}

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}`)
      break
    case 'catalog.published':
      revalidateTag('catalog')
      break
    default:
      return NextResponse.json({ message: 'Unhandled event type' }, { status: 400 })
  }

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

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) paired with explicit cache: 'no-store' on the dynamic fetches:

// app/products/[id]/page.tsx
export const experimental_ppr = true

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 function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <ProductHeader id={params.id} />
      <Suspense fallback={<StockSkeleton />}>
        <StockLevel id={params.id} />
      </Suspense>
    </div>
  )
}

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.

Next.js provides unstable_cache (stable in practice despite the name) to extend 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.

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.

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.

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.

Architecture Matters More Than API Knowledge

Caching in Next.js 15 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.