Skip to content
Muhammad Moeed

Next.js Caching Explained: Setup, Revalidation, and Fixes

Understand Next.js caching with a working example. Learn use cache, cacheLife, updateTag, and how to fix old data that stays on your page.

Next.js caching saves a result so your app can reuse it instead of doing the same work again. The confusing part is deciding when that saved result should change.

Imagine you change a product’s name in your database. The save succeeds, but the website still shows the old name. Before changing your database code, check whether the page is reading a cached result.

This guide starts with a small app you can run locally. You will see a result stay the same across reloads, press a button to replace it, and learn which Next.js function belongs in each situation.

Version scope: This guide uses the App Router and Cache Components in Next.js 16.4. It does not use the older Pages Router. Next.js 16.4 enables Cache Components in new create-next-app projects; existing projects still need their configuration checked. See the Next.js 16.4 release notes.

What is Next.js caching?

A cache is a saved copy of something your app has already calculated or loaded.

For a product page, the flow might be:

  1. Ask the database for the product.
  2. Save the result in a cache.
  3. Reuse that result for another visit.
  4. Replace it when it becomes too old or when the product changes.

Step four is called revalidation. You are telling the app when it should check the source again. Invalidation means marking a saved result as no longer usable or no longer fresh.

Caching introduces a tradeoff: less repeated work, but a saved result can lag behind the source. Decide how much delay each screen can tolerate. A public article and a customer’s payment status have different needs.

With Cache Components, you can mark selected functions or components with use cache. Other parts of the page can still load information for the current request. You do not need to cache the whole application. The official caching guide explains this model.

Build a small Next.js caching demo

This example needs no database, account, or API key. It creates a timestamp and an ID, caches them, and provides a button that expires that cache.

The ID makes the behavior visible: the same ID means the saved result was reused. A new ID means the function ran again.

1. Create the project

Install a supported Node.js LTS release first. Next.js requires Node.js 20.9 or later. Check your installation:

node --version
npm --version

In your terminal, run these commands one line at a time:

mkdir next-cache-demo
cd next-cache-demo
npm init -y
npm install next@latest react@latest react-dom@latest
npm install -D typescript @types/node @types/react @types/react-dom
npm pkg set scripts.dev="next dev" scripts.build="next build" scripts.start="next start"
mkdir app

We use a manual setup so the example has no generated fonts or styling to configure. Keep the generated package-lock.json to record the versions you installed. Installation details are in the Next.js setup guide.

2. Enable Cache Components

Create next.config.ts in the project root:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Create app/layout.tsx:

import type { ReactNode } from "react";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

The layout supplies the HTML document around your page.

3. Create the cached function

Create app/snapshot.ts:

import { randomUUID } from "node:crypto";
import { cacheLife, cacheTag } from "next/cache";

export async function getSnapshot() {
  "use cache";

  cacheLife("hours");
  cacheTag("demo-snapshot");

  return {
    id: randomUUID(),
    createdAt: new Date().toISOString(),
  };
}

Three lines control the cache:

  • "use cache" makes this function’s result reusable.
  • cacheLife("hours") selects a built-in lifetime profile.
  • cacheTag("demo-snapshot") attaches a label we can target later.

A tag is a label for invalidation. It is not a user permission or a unique cache key. Several cached results can share a tag. See the cacheTag reference.

4. Add the reset action

Create app/actions.ts:

"use server";

import { updateTag } from "next/cache";

export async function resetSnapshot() {
  updateTag("demo-snapshot");
}

A Server Action is a server-side function that a form or another client interaction can call. This action expires the result labeled demo-snapshot.

updateTag is for Server Actions. The next read waits for a fresh result instead of receiving the expired copy. This is useful when someone saves a change and expects to see it immediately. See the updateTag reference.

5. Display the result

Create app/page.tsx:

import { getSnapshot } from "./snapshot";
import { resetSnapshot } from "./actions";

export default async function HomePage() {
  const snapshot = await getSnapshot();

  return (
    <main>
      <h1>Next.js cache demo</h1>
      <p>Reload this page, then compare the ID.</p>
      <p>ID: <code data-testid="snapshot-id">{snapshot.id}</code></p>
      <p>Created: <time>{snapshot.createdAt}</time></p>
      <form action={resetSnapshot}>
        <button type="submit">Generate a fresh result</button>
      </form>
    </main>
  );
}

Your files should now look like this:

next-cache-demo/
  app/
    actions.ts
    layout.tsx
    page.tsx
    snapshot.ts
  next.config.ts
  package.json
  package-lock.json

Next.js will generate the TypeScript configuration files when you first run or build the project.

6. Test a production build locally

Run:

npm run build
npm run start

Open http://localhost:3000. Leave that terminal running.

  1. Copy the displayed ID.
  2. Reload the page a few times within a minute. The ID should stay the same.
  3. Press Generate a fresh result. The page should show a different ID.
  4. Reload again. The new ID should remain.

The initial timestamp may come from the build because Next.js can prepare this page before a visitor arrives. That is expected.

This exercise isolates caching. It does not modify a database, and its public button is only a teaching tool. Do not add an unrestricted cache-reset button to a public production app.

How does cacheLife control freshness?

The hours profile does not mean every browser screen automatically updates once an hour. Cache lifetimes describe when stored results can be reused and when later requests need fresher data.

For more control, replace cacheLife("hours") in the demo with:

cacheLife({
  stale: 300,
  revalidate: 600,
  expire: 3600,
});

The values are seconds:

SettingPlain meaning
stale: 300The client can reuse its cached content for five minutes before checking the server during navigation.
revalidate: 600After ten minutes, a server request can receive the saved result while a fresh one is prepared.
expire: 3600After one hour without a successful refresh, a request must wait for fresh data.

There is no background timer that continuously reloads every open tab. Requests drive this process. Keep expire greater than revalidate. The cacheLife documentation describes the profiles and timing rules.

updateTag vs revalidateTag vs revalidatePath

Choose the function based on what changed and whether a short delay is acceptable.

ToolWhen to use it
updateTagExpire tagged data after a save so the user sees the change. Server Actions only.
revalidateTagMark tagged data for refresh. With "max", a later request can get the old result while fresh data loads.
revalidatePathRevalidate a particular page or layout. Shared data on other routes may need tag invalidation too.
router.refreshRender the current route again from the client. Does not invalidate the server cache.

For a tag named products, use updateTag("products") or revalidateTag("products", "max") as appropriate. For a route, use revalidatePath("/products"). The client call is router.refresh().

revalidateTag can run in Server Actions or Route Handlers. A Route Handler is an endpoint in your app, such as one receiving a CMS notification. The recommended "max" behavior refreshes on a later request, rather than rebuilding every affected page immediately. Avoid the deprecated one-argument form. See the revalidateTag reference.

revalidatePath is useful for a route-specific change. For example, changing a page’s arrangement may require refreshing /products. If the same product data also appears on /offers, invalidate the relevant data tag too. See the revalidatePath reference.

For a real edit form, the order should be:

  1. Verify who is making the request.
  2. Check that they can edit this record.
  3. Validate and save the input.
  4. After the save succeeds, invalidate the relevant cache.

Put these checks on the server. Hiding a button does not protect a Server Action. The Next.js authentication guide covers authorization for mutations.

Why is my Next.js cache not updating?

You refreshed the page but kept the server cache

router.refresh() asks the server for the route again. If the server still considers its cached result valid, the page can show the same value.

Fix the server invalidation first. Then refresh the client if your interaction requires it. The useRouter documentation explicitly separates these operations.

Your save happened outside the app

Editing a database row directly does not call your Server Action. Changing content in a CMS also needs a path back to your application.

Arrange an authenticated webhook to invalidate the appropriate tag, or choose a lifetime that fits the acceptable delay. Check the webhook’s response and logs; a successful CMS save does not prove that the notification reached your app.

Your tag does not match

Compare the label where you cache the result with the label where you invalidate it. In our demo, both must be demo-snapshot.

A tag naming convention helps. For example, use one documented format such as product:42 across the read and write code, rather than typing slightly different labels in several files.

You read cookies inside a cached function

Regular use cache functions cannot read request-specific APIs such as cookies() or headers(). Read request information outside the cached scope and pass the needed values as arguments.

For private data, also verify access before returning the result. A user ID in a cache argument does not establish that the caller owns that account. See the use cache constraints.

Local and deployed results differ

The default runtime cache uses server memory. One local server can keep reusing that memory. A serverless deployment can send requests to different short-lived instances, so the same reuse is not guaranteed.

If you need a shared runtime cache, investigate a supported remote cache handler. Check your hosting platform before assuming it exists. Build-time caching and runtime caching also behave differently. The runtime caching notes explain this distinction.

Test the deployed app as well as the local production build. Record the result ID, the request, and which server handled it when that information is available.

An older tutorial uses a different caching model

Check the installed version with:

npm ls next

Then check whether cacheComponents is enabled. Do not combine older route-level recipes with this guide without checking their compatibility.

If the app already uses cached fetch requests, inspect those options too. A separate cached layer can keep returning an old response even after another layer refreshes. See the fetch reference.

Common questions

Does every page need use cache?

No. Start with a read that repeats often and can tolerate some delay. For example, a public list of product categories is a clearer first candidate than an account balance. Add caching where you can explain both the benefit and the invalidation rule.

Does caching replace the database?

No. Keep the database as the source of truth. Your app must still work when a cache entry disappears and the original read needs to run again.

Can I clear everything after every change?

Broad invalidation may be convenient while debugging, but it discards useful results unrelated to the edit. Prefer a tag or route that matches the changed data, then test every screen that depends on it.

Should I delete the .next folder to fix stale data?

Deleting local build output cannot serve as your production data-update workflow. First reproduce the issue, check the source value, and trace the cached read and invalidation. Rebuild when investigating build artifacts, not as a replacement for handling ordinary edits.

Apply this to your own app

Pick one screen. Write down where its data comes from, how long an old value is acceptable, and which event should invalidate it. Implement that one path and test it before adding more cached reads.

Useful next reads: