Data Fetching and Caching in Next.js (App Router)

1. Overview

Next.js App Router automatically caches the results of fetch on the server to reduce backend load and speed up pages. However, this caching mechanism is quite different from the standard HTTP caching you’re familiar with, and if you don’t understand it well, you’ll easily run into situations where “old data refuses to update” or conversely “every page rerenders from scratch, unbearably slow.”

This guide covers the cache layers of Next.js: the fetch cache, time-based revalidation, tag-based revalidation, and dynamic rendering. After reading, you’ll know how to choose the right fetch strategy for each type of page (static, ISR, dynamic) and debug when cache acts “stubborn.”

Note: this article applies to App Router (Next.js 13+, version 14/15 recommended). Pages Router uses a different mechanism (getStaticProps, getServerSideProps) and is outside the scope of this guide.

2. Four Cache Layers of Next.js

Next.js has 4 distinct caching mechanisms that can easily be confused if lumped together:

Mechanism Location Purpose
Request Memoization Server, within one render Deduplicate identical fetch calls in the same request
Data Cache Server, across requests Cache fetch results long-term, can be revalidated
Full Route Cache Server, build time Cache HTML + RSC payload of static routes
Router Cache Client, within session Cache navigation results for fast back/forward

This article focuses on Data Cache because that’s what you control directly via fetch options.

3. Default fetch: “indefinite” cache

Inside a Server Component, fetch is patched by Next.js to cache by default:

// app/products/page.tsx
async function getProducts() {
  const res = await fetch("https://api.shop.example.com/products");
  if (!res.ok) throw new Error("Failed to fetch products API");
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}
  • fetch without passing cache → Next.js defaults to force-cache.
  • The result is stored in the Data Cache, shared across all subsequent requests, never expires unless you revalidate it.
  • This page enters the Full Route Cache: built into static HTML, served like a static page.

This is why many people are surprised: they change data in the backend but the page doesn’t update — because Next.js treats this as static data.

4. Controlling Cache with the cache Option

To always fetch fresh data, use no-store:

async function getOrderStatus(orderId: string) {
  const res = await fetch(
    `https://api.shop.example.com/orders/${orderId}`,
    { cache: "no-store" }
  );
  return res.json();
}
  • cache: "no-store" completely disables Data Cache for this request.
  • The route containing this fetch will be marked as dynamic, rerendering on each request.
  • Good for data that changes frequently: processing orders, real-time prices, dashboards.

Note: just one fetch using no-store in a page makes the entire page dynamic — even other fetch calls in the same page still use force-cache.

5. Revalidate by Time (ISR)

To cache but auto-refresh after N seconds (Incremental Static Regeneration), use next.revalidate:

async function getCategories() {
  const res = await fetch(
    "https://api.shop.example.com/categories",
    { next: { revalidate: 300 } } // 5 minutes
  );
  return res.json();
}
  • revalidate: 300 means: data is considered “stale” after 300 seconds.
  • The first request after the 300s mark still returns old data (stale-while-revalidate), but Next.js triggers a fresh fetch in the background.
  • The next request receives the new data.

You can apply this to the entire route by exporting a variable at the top of the file:

// app/blog/[slug]/page.tsx
export const revalidate = 300;

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const res = await fetch(`https://api.blog.example.com/posts/${params.slug}`);
  return <article>{/* ... */}</article>;
}
  • export const revalidate applies to all fetches in the route if that fetch doesn’t declare its own revalidate.
  • If a specific fetch declares a different next.revalidate, that value wins for that fetch.

6. Revalidate by Tag: revalidateTag

Time-based revalidation works well for content that changes at regular intervals, but it doesn’t fit when you want to refresh immediately when data changes (e.g., admin just updated a product price). That’s when you use cache tags.

// app/products/[id]/page.tsx
async function getProduct(id: string) {
  const res = await fetch(
    `https://api.shop.example.com/products/${id}`,
    { next: { tags: [`product-${id}`] } }
  );
  return res.json();
}

When admin updates a product, call revalidateTag in a Route Handler or Server Action:

// app/actions/update-product.ts
"use server";

import { revalidateTag } from "next/cache";

export async function updateProduct(id: string, formData: FormData) {
  await fetch(`https://api.shop.example.com/products/${id}`, {
    method: "PATCH",
    body: formData,
  });

  revalidateTag(`product-${id}`); // clear cache with this tag
}
  • tags attach a “label” to the fetch’s cache entry.
  • revalidateTag(tag) clears all Data Cache entries with that tag, regardless of which route uses it.
  • The next access will fetch fresh data and cache it with the new tag.

This is the most powerful mechanism because it decouples “when to invalidate” from “which page displays the data” — similar to cache tagging in Spring Cache or Redis.

7. Revalidate by Path: revalidatePath

If you don’t use tags, you can revalidate directly by route path:

"use server";

import { revalidatePath } from "next/cache";

export async function publishPost(slug: string) {
  // ... call publish API ...
  revalidatePath(`/blog/${slug}`);   // just this page
  revalidatePath("/blog", "page");    // or the whole list
}
  • revalidatePath("/blog/[slug]") clears the Full Route Cache + Data Cache of that exact route.
  • The second parameter ("page" or "layout") determines whether to also revalidate child routes using that layout.
  • Different from revalidateTag: revalidatePath is tied to a specific route, while tags can be used across multiple routes.

Note: revalidateTag/revalidatePath only work inside Server Actions or Route Handlers; you can’t call them directly from Client Components.

8. Dynamic Rendering: When Caching No Longer Makes Sense

Some APIs cause Next.js to automatically switch a route to dynamic, bypassing Full Route Cache:

import { cookies, headers } from "next/headers";

export default async function AccountPage() {
  const sessionToken = (await cookies()).get("session")?.value;
  const userAgent = (await headers()).get("user-agent");

  const res = await fetch("https://api.shop.example.com/me", {
    headers: { Authorization: `Bearer ${sessionToken}` },
    cache: "no-store",
  });

  return <div>Hello, {(await res.json()).name}</div>;
}
  • Calling cookies() or headers() automatically marks the route as dynamic — it makes sense because data depends on the specific user.
  • Combine with cache: "no-store" to ensure data is always fresh per session.

Explicitly force dynamic rendering (e.g., when you know you can’t cache):

export const dynamic = "force-dynamic";

9. Route Handlers Also Have Their Own Cache

Route Handlers (app/api/.../route.ts) with GET method also follow the same caching rules as Server Components:

// app/api/stock/route.ts
export const revalidate = 60;

export async function GET() {
  const res = await fetch("https://api.shop.example.com/stock");
  const data = await res.json();
  return Response.json(data);
}
  • GET that doesn’t use the Request object (doesn’t read request.url for query params) may be cached statically.
  • If the handler uses request.headers, request.cookies, or methods other than GET (POST, PUT, etc.) → always dynamic, no caching.

10. Client Components: Caching Belongs to a Different Library

Next.js’s fetch cache only applies on the server. In Client Components, you fetch yourself with useEffect or use a separate caching library like SWR:

npm install swr
"use client";

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function CartBadge({ userId }: { userId: string }) {
  const { data, isLoading } = useSWR(`/api/cart/${userId}`, fetcher, {
    refreshInterval: 10_000, // poll every 10s
  });

  if (isLoading) return <span>...</span>;
  return <span>{data.itemCount} items</span>;
}
  • SWR has its own cache, deduplicates requests, revalidates on focus — completely independent of the server’s Data Cache.
  • Use it when you need frequently updating data on the client without reloading the entire page.

11. Writing Tests for Cache Key Logic

Next.js’s caching mechanism itself is hard to unit test directly, but the logic for creating tags/paths is often worth testing separately — avoid typos in tags that cause revalidateTag mismatches.

// lib/cache-keys.ts
export function productTag(id: string): string {
  return `product-${id}`;
}

export function categoryTag(id: string): string {
  return `category-${id}`;
}
// lib/cache-keys.test.ts
import { describe, it, expect } from "vitest";
import { productTag, categoryTag } from "./cache-keys";

describe("cache-keys", () => {
  it("generates product tag in correct format", () => {
    expect(productTag("sku-123")).toBe("product-sku-123");
  });

  it("product and category tags don't collide", () => {
    const id = "42";
    expect(productTag(id)).not.toBe(categoryTag(id));
  });
});
  • Extracting tag generation into a separate module ensures that everywhere you call fetch (to attach tags) and revalidateTag (to clear tags) always use the same format.
  • This is a real mistake people make often: one place writes product-${id}, another writes products-${id} → revalidation never matches.
✓ cache-keys > generates product tag in correct format
✓ cache-keys > product and category tags don't collide

12. Common Mistakes

  • Forgetting that fetch caches indefinitely by default: deploy and data doesn’t change because you’re still using old cache from the previous build (with static export) or from the first fetch (with server deploy). Fix with revalidate or no-store.
  • Mixing no-store and cache in the same component unnecessarily: just one no-store fetch makes the entire page dynamic, losing the cache benefits of other fetches.
  • Calling revalidateTag/revalidatePath outside Server Action/Route Handler: has no effect, Next.js will error or silently ignore it depending on version.
  • Using regular fetch (not patched by Next.js) inside third-party libraries (e.g., SDKs using axios or vanilla undici): not managed by Data Cache, you must revalidate another way (e.g., cache at a different layer or write a wrapper calling standard fetch).
  • Not tagging by specific entity: using overly broad tags ("products" for all products) means one small update invalidates the entire cache, losing the benefit of per-item caching.

When NOT to cache: personalized data per session (shopping cart, balance, notifications), real-time data (stock prices, payment status), or any response depending on cookies()/headers(). These cases should use no-store or force-dynamic from the start — don’t try to force cache then revalidate constantly — it’s both complex and wrong for the data’s nature.

13. Conclusion

Caching in Next.js App Router has many layers, but the part you control most directly is Data Cache through fetch options: cache, next.revalidate, next.tags. Quick selection rule: data changes rarely → revalidate by time; data changes on specific events (admin update) → attach tags and use revalidateTag; data is per-user or real-time → no-store/force-dynamic. Master these three groups, and you’ll rarely have to guess “why won’t this data update?” again.

nextjsreactcachingapp-routerserver-components