Module 6: Data Fetching, Caching & Revalidation in Next.js 16

Module 6: Data Fetching, Caching & Revalidation in Next.js 16

AI Reading

Quick summary of this article

Learn the Next.js 16 caching model end to end: uncached fetch by default, the use cache directive, cacheLife and cacheTag, plus revalidateTag, updateTag and refresh invalidation.

  • In Next.js 16 fetch() is not cached by default — caching is always an explicit decision.
  • Wrapping a function with use cache (enabled via cacheComponents: true) caches any async work, not just fetch.
  • cacheTag labels cached entries so a Server Action can invalidate exactly the right data with revalidateTag(tag, 'max').
  • updateTag gives read-your-writes in Server Actions: expiry and refresh happen inside the same request.

Complete Next.js 16 Series — Module 6 of 12

Caching is where most Next.js bugs are born. The mental model is simple once it clicks: render on the server, cache deliberately, invalidate precisely.

Fetching in a Server Component

Any async Server Component can fetch directly:

async function getPosts() {
  const res = await fetch("https://api.example.com/posts", {
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error("Failed to load posts");
  return res.json() as Promise<Post[]>;
}

export default async function BlogIndex() {
  const posts = await getPosts();
  return (
    <ul className="divide-y">
      {posts.map((p) => (
        <li key={p.id} className="py-4">{p.title}</li>
      ))}
    </ul>
  );
}

next: { revalidate: 60 } means: serve the cached response, and after 60 seconds fetch a fresh copy in the background (stale-while-revalidate).

The three fetch modes

// 1. Cached with a TTL
fetch(url, { next: { revalidate: 300 } });

// 2. Fully static — cache forever until a tag is invalidated
fetch(url, { cache: "force-cache", next: { tags: ["posts"] } });

// 3. Never cached — always fresh, makes the route dynamic
fetch(url, { cache: "no-store" });

Everything else — no options at all — is uncached by default in Next.js 15 and 16. This is the change that trips up developers coming from Next.js 13, where fetch was cached aggressively.

Request Deduplication Comes Free

If three components fetch the same URL in one render pass, React memoises the promise. You do not need to thread data down through props:

// header.tsx
const user = await getUser(); // runs
// sidebar.tsx
const user = await getUser(); // deduplicated — no second request

Deduplication is per-request only. It is not a cache — the next request runs the fetch again. Only caching survives across requests.

use cache: Cache Any Work, Not Just Fetch

Enable Cache Components in next.config.ts:

import type { NextConfig } from "next";

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

export default nextConfig;

Now use cache caches the result of any async function — a database query, a heavy computation, a third-party SDK call:

import { cacheLife, cacheTag } from "next/cache";

async function getProducts() {
  "use cache";
  cacheLife("hours");          // built-in profile
  cacheTag("products");        // label for invalidation

  return db.product.findMany({ orderBy: { createdAt: "desc" } });
}

Profiles describe freshness and revalidation in one word:

  • "seconds" — very short-lived, near-real-time
  • "minutes" — dashboards and feeds
  • "hours" — typical content sites
  • "days" — rarely changing reference data
  • "max" — effectively permanent until invalidated

You can also define a custom profile:

import { unstable_cacheLife as cacheLife } from "next/cache";

cacheLife({
  stale: 300,      // client considers it fresh for 5 minutes
  revalidate: 900, // server regenerates after 15 minutes
  expire: 86400,   // hard limit of one day
});

use cache is the modern replacement for unstable_cache, and unlike fetch caching it works for non-HTTP data sources.

Invalidating on Write

Caching is only half the story. The other half is making a write appear immediately.

// src/app/admin/products/actions.ts
"use server";

import { revalidateTag, updateTag } from "next/cache";

export async function createProduct(formData: FormData) {
  const name = String(formData.get("name"));
  await db.product.create({ data: { name } });

  // Read-your-writes: expires the entry and refreshes it in the same response,
  // so the author immediately sees their new product.
  updateTag("products");
}

export async function syncCatalogFromSupplier() {
  await runSupplierImport();

  // Stale-while-revalidate: visitors keep seeing the old catalog while
  // it regenerates in the background. `revalidateTag` in Next.js 16
  // requires a cacheLife profile as its second argument.
  revalidateTag("products", "max");
}

Choosing between them:

  • updateTag(tag) — Server Actions only. The user made a change, so they must see it now. Slightly slower response.
  • revalidateTag(tag, profile) — background content that can be briefly stale. Faster response, stale-while-revalidate semantics.
  • refresh() — re-fetches uncached data for the current request inside a Server Action, without touching the cache.

Migrating from Next.js 15? revalidateTag("posts") with one argument now produces a TypeScript error. Add a profile: revalidateTag("posts", "max").

Route-Level Caching Controls

Two exports still control a whole route:

export const revalidate = 3600;  // ISR: regenerate at most hourly
export const dynamic = "force-static"; // never use request data

But prefer segment-level caching with use cache — it is more granular and composes with streaming.

Building a Real Data Layer

Put fetching in one module so tags and profiles stay consistent:

// src/lib/posts.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getPost(slug: string) {
  "use cache";
  cacheLife("days");
  cacheTag("posts", `post-${slug}`);

  return db.post.findUnique({ where: { slug } });
}

export async function getRecentPosts(limit = 5) {
  "use cache";
  cacheLife("hours");
  cacheTag("posts");

  return db.post.findMany({ orderBy: { publishedAt: "desc" }, take: limit });
}

Because each post is tagged with its own post-${slug}, an edit to one article invalidates only that article — not the entire archive.

Streaming with Suspense

Fetching sequentially creates waterfalls. Split slow sections behind Suspense so the fast shell ships first:

import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div className="space-y-8">
      <h1 className="text-2xl font-bold">Dashboard</h1>

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

Both components fetch in parallel, and each one streams in as soon as it resolves. With loading.tsx you get this automatically for a whole segment; <Suspense> gives you control per section.

Parallel Fetches in One Component

If you need several independent values before rendering, start them together:

const [user, orders, invoices] = await Promise.all([
  getUser(),
  getOrders(),
  getInvoices(),
]);

Or start them eagerly and await later, which keeps the component free to render the shell first:

const ordersPromise = getOrders();
const invoicesPromise = getInvoices();

return (
  <Suspense fallback={<Skeleton />}>
    <Orders promise={ordersPromise} />
    <Invoices promise={invoicesPromise} />
  </Suspense>
);

Module 6 Recap

  • fetch is uncached by default — cache explicitly with revalidate, force-cache or no-store.
  • Duplicate fetch calls in one render are deduplicated automatically.
  • use cache (with cacheComponents: true) caches any async function; combine with cacheLife and cacheTag.
  • revalidateTag(tag, profile) for background refresh; updateTag(tag) for read-your-writes in Server Actions.
  • Tag granularly (post-${slug}) so invalidation stays cheap.
  • Stream with <Suspense> and parallelise with Promise.all.

Next up — Module 7: forms, Server Actions and validation with Zod.

Leave a Reply

Your email address will not be published. Required fields are marked *