# Caching and Rendering in Next.js in 2026: From 14 to 16

> Same questions as in 2025, new answers: SSG, ISR, SSR, PPR and revalidation in Next.js 16 with Cache Components. Plus what's left of my caching trap.

- URL: https://www.maciejbednarczyk.com/en/blog/nextjs-caching-rendering-2026
- Author: Maciej Bednarczyk
- Language: en
- Published: 2026-11-10

## Same questions, new answers

In May 2025 [I wrote about how Next.js renders and caches pages](https://www.maciejbednarczyk.com/blog/mastering-caching-and-rendering-in-next-js): SSG, ISR, SSR, PPR, CSR, `react.cache()` and manual revalidation, each with an example. That post was written for Next.js 14 and a model where `export const revalidate` and `export const dynamic` controlled caching.

Since Next.js 16.4 (October 6, 2026), the Next.js team recommends Cache Components for every app, and `create-next-app` turns it on by default. Next.js 17 is expected to make it the only model.

So this post takes exactly the same route as that one. Section by section: first how I did it in Next.js 14, then how I do it today in 16, and why. The trap that cost me the most time in 2025 is also where it was, in the ISR section. It just looks different now.

Every error message and measurement in this post comes from a small demo I built on Next.js 16.4.6 (`next build` + `next start`).

* * *

## What changed, in one paragraph?

In Next.js 14 a page was static by default, and you turned caching off where it got in the way. In 16 it is the other way round: everything is dynamic, and you opt in to caching where you want it, with the `"use cache"` directive. Next.js builds a static shell of the page from that, plus dynamic parts in `<Suspense>` that stream in at request time.

![Next.js 14 vs 16: one lifetime for the whole page vs a static shell where each part has its own cacheLife](https://assets.cmssy.io/6ae4795c65113d877fc8d877/707965c5-2892-4936-a6e4-bfb9e2c62e8c-nextjs-cache-2026-01-next14-vs-next16-en.png)

Two flags turn the whole model on. Without `partialPrefetching`, Next.js 16.4 logs a warning.

```typescript
import type { NextConfig } from "next";

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

export default nextConfig;
```

* * *

## What is caching in Next.js today?

The idea has not changed: you remember a result so you don't compute it twice. What changed is who decides. In 14, segment config and `fetch` options decided. In 16, you decide, with three calls in your code:

-   `"use cache"` marks what is cached: a data function or a whole component;
-   `cacheLife()` says for how long;
-   `cacheTag()` says how to invalidate it.

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

// Data level: one function, reused across pages
export async function getPosts() {
  "use cache";
  cacheLife("days");
  cacheTag("posts");
  return db.post.findMany();
}

// UI level: the rendered component is cached
async function PostList() {
  "use cache";
  cacheLife("hours");
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}
```

`cacheLife` takes a preset profile or an object with `stale`, `revalidate` and `expire`:

<table><thead><tr><th>Profile</th><th>stale</th><th>revalidate</th><th>expire</th></tr></thead><tbody><tr><td>seconds</td><td>30 s</td><td>1 s</td><td>1 min</td></tr><tr><td>minutes</td><td>5 min</td><td>1 min</td><td>1 h</td></tr><tr><td>hours</td><td>5 min</td><td>1 h</td><td>1 day</td></tr><tr><td>days</td><td>5 min</td><td>1 day</td><td>1 week</td></tr><tr><td>weeks</td><td>5 min</td><td>1 week</td><td>30 days</td></tr><tr><td>max</td><td>5 min</td><td>30 days</td><td>1 year</td></tr></tbody></table>

Remember one threshold; it comes back in every section below. A cache with `revalidate: 0` or `expire` under 5 minutes is short-lived: it stays out of the shell and becomes a dynamic hole. Of the presets, only `seconds` does that.

* * *

## Static page (SSG)

**In Next.js 14** a page without dynamic data was static on its own, and if you wanted to be sure, you forced it:

```tsx
export const dynamic = "force-static";

export default function About() {
  return <h1>About me</h1>;
}
```

**In Next.js 16** it is enough that the page reads nothing from the request. Then it goes into the static shell in full at build time. Data from a database or a CMS goes in `"use cache"`, so it ends up in the shell too:

```tsx
import { cacheLife } from "next/cache";

async function Bio() {
  "use cache";
  cacheLife("max");
  const bio = await cms.getBio();
  return <p>{bio.text}</p>;
}

export default function About() {
  return (
    <main>
      <h1>About me</h1>
      <Bio />
    </main>
  );
}
```

`force-static` has no direct replacement. Instead of forcing a page to be static, you can guard it: `ensureStatic` fails the build if anything dynamic ends up on the page. The best place is the layout of a whole section, a blog for example:

```tsx
export const ensureStatic = "navigation";
```

I tested it on Next.js 16.4.6: I added a component that reads `cookies()` inside `<Suspense>` to such a page. The build stopped:

```
Error: Route "/static-guard": Next.js encountered uncached or runtime data on a route that must be fully static.
This route is configured to be fully static, but some data requires rendering at request time.
```

That is exactly the check I was missing in 2025.

* * *

## Incremental Static Regeneration (ISR)

**In Next.js 14** ISR was a static page with a timer. `revalidate` said how often to rebuild it, and `generateStaticParams` said which paths to generate at build time:

```tsx
export const revalidate = 3600;

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

export default async function Post({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return <Article post={post} />;
}
```

**In Next.js 16** the timer moves from the file to the data. `generateStaticParams` stays, and `cacheLife` replaces `revalidate`:

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

export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

async function getPost(slug: string) {
  "use cache";
  cacheLife("hours");
  cacheTag("posts", `post-${slug}`);
  return cms.getPost(slug);
}

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <Article post={post} />;
}
```

You can't leave the old `export const revalidate` in place. With Cache Components on, the build fails, once for `revalidate` and once for `dynamic`:

```
Error: Route segment config "revalidate" is not compatible with `nextConfig.cacheComponents`. Please remove it.
```

That is good news for a migration: the compiler shows you every place to rewrite.

### The 2025 trap: the shortest lifetime wins. What's left of it?

In 2025 I warned: Next.js takes the shortest `revalidate` from the whole render tree. One `fetch` with a minute in the layout, and a page that was supposed to live for a week rebuilds every minute. No warning. The only way to notice was the `age` header in the Network tab.

In the new model that trap split in two.

**The loud version.** A short-lived cache inside an outer `"use cache"` that has no `cacheLife` of its own fails the prerender:

```tsx
async function LiveStatus() {
  "use cache";
  cacheLife("seconds");
  const status = await getStatus();
  return <p>Status: {status.state}</p>;
}

export default async function DocsPage() {
  "use cache"; // no cacheLife
  const docs = await getDocs();
  return (
    <main>
      <Docs docs={docs} />
      <LiveStatus />
    </main>
  );
}
```

```
Error: Route "/docs": A nested `"use cache"` with a short `expire` (under 5 minutes) is inside
an outer `"use cache"` that has no `cacheLife()`. Add `cacheLife()` to the outer one to choose
whether to prerender it with a longer `expire` or keep it dynamic with a short `expire`.
```

That is a good change. What you had to dig out of headers in 2025 now stops the build.

**The silent version.** When the outer scope has its own `cacheLife`, it wins, even when it is longer. A status bar with `minutes` inside a layout with `hours` shows data from an hour ago:

```tsx
export default async function DocsLayout({ children }: { children: React.ReactNode }) {
  "use cache";
  cacheLife("hours"); // this one decides
  return (
    <section>
      <StatusBar /> {/* "use cache" + cacheLife("minutes") */}
      {children}
    </section>
  );
}
```

No error, no warning, nothing in the dev overlay. The 2025 rule flipped: back then the shortest lifetime won, now the outer one wins.

![The same status bar: inside a layout with cacheLife("hours") it stays frozen, next to the layout it refreshes every minute](https://assets.cmssy.io/6ae4795c65113d877fc8d877/c02ef0da-d549-4d91-b903-1c4fa2acd120-nextjs-cache-2026-04-frozen-status-bar-en.gif)

I tested it on Next.js 16.4.6 with a production build (`next start`). For 16 minutes I reloaded two versions of the same page once a minute:

<table><thead><tr><th>Reload</th><th>Bar inside a layout with hours</th><th>Bar next to the layout</th></tr></thead><tbody><tr><td>08:38</td><td>08:35:53</td><td>08:37:10</td></tr><tr><td>08:41</td><td>08:35:53</td><td>08:40:22</td></tr><tr><td>08:44</td><td>08:35:53</td><td>08:43:22</td></tr><tr><td>08:47</td><td>08:35:53</td><td>08:46:22</td></tr><tr><td>08:50</td><td>08:35:53</td><td>08:49:22</td></tr><tr><td>08:53</td><td>08:35:53</td><td>08:52:22</td></tr></tbody></table>

08:35:53 is the build time. The bar inside the layout never changed. The bar next to it shows the time from a minute earlier, because that is how stale-while-revalidate works: a reload gets the previous version and triggers a new one.

One place does give this trap away: the route table at the end of `next build`. The Revalidate column shows the lifetime that really applies to the route:

```
Route (app)          Revalidate  Expire
├ ○ /trap2-fixed             1m      1h
├ ○ /trap2-silent            1h      1d
```

If the bar was meant to refresh every minute and the route says `1h`, you just fell into the silent version.

**The fix for both versions is the same:** don't nest a shorter-lived cache inside a longer one. Put them side by side:

```tsx
export default function DocsLayout({ children }: { children: React.ReactNode }) {
  return (
    <section>
      <DocsShell /> {/* "use cache" + cacheLife("hours") */}
      <Suspense fallback={<p>Loading status…</p>}>
        <StatusBar /> {/* "use cache" + cacheLife("minutes") */}
      </Suspense>
      {children}
    </section>
  );
}
```

* * *

## Server-Side Rendering (SSR)

**In Next.js 14** you switched a whole page to dynamic with one export:

```tsx
export const dynamic = "force-dynamic";

export default async function Dashboard() {
  const user = await getCurrentUser();
  return <h1>Hi, {user.name}</h1>;
}
```

**In Next.js 16** dynamic is the default, but for a part of the page, not the whole page. The part that reads the request goes into `<Suspense>`, the rest stays in the static shell:

```tsx
import { Suspense } from "react";
import { cookies } from "next/headers";

async function Greeting() {
  const session = (await cookies()).get("session")?.value;
  const user = await getUserBySession(session);
  return <h1>Hi, {user.name}</h1>;
}

export default function Dashboard() {
  return (
    <main>
      <Suspense fallback={<h1>Hi!</h1>}>
        <Greeting />
      </Suspense>
      <DashboardNav /> {/* static, in the shell */}
    </main>
  );
}
```

If a dynamic part is not inside `<Suspense>`, Next.js reports an error instead of quietly rendering the whole page on demand.

### Watch out: a timestamp is request data too

`new Date()`, `Date.now()`, `Math.random()` and `crypto.randomUUID()` outside a cache block the prerender. First decide which time you mean:

```tsx
import { connection } from "next/server";

// The time of this request: a dynamic hole
async function RequestTime() {
  await connection();
  return <p>{new Date().toISOString()}</p>;
}

// The time the content was built: cached with it
async function BuiltAt() {
  "use cache";
  cacheLife("hours");
  return <p>{new Date().toISOString()}</p>;
}
```

Without `connection()` the build stops with a very specific message that lists three ways out. In `next dev` the same error shows up the first time you open the page:

```
Error: Route "/time": Next.js encountered the unstable value `new Date()` while prerendering.
Ways to fix this:
  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`)
  - [cache] Prerender and cache the value with "use cache"
  - [client] Render the value on the client with "use client"
```

* * *

## Mixed page (formerly PPR)

In 2025 Partial Prerendering was a separate, experimental feature. In Next.js 16 it is simply how every page works: a static shell plus dynamic holes in `<Suspense>`. There is nothing extra to turn on.

![A product page in Next.js 16: what goes into the static shell and what is a dynamic hole](https://assets.cmssy.io/6ae4795c65113d877fc8d877/e134cef1-d0fd-44fc-b26b-2e6dfb04d482-nextjs-cache-2026-02-shell-and-holes-en.png)

A hole is a server component that reads request data (`cookies()`, `headers()`, `searchParams`, `connection()`) or uses a short-lived cache.

```tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { cacheLife } from "next/cache";

async function ProductInfo({ id }: { id: string }) {
  "use cache";
  cacheLife("days");
  const product = await getProduct(id);
  return <ProductDetails product={product} />; // in the shell
}

async function Cart() {
  const cartId = (await cookies()).get("cart")?.value;
  const cart = await getCart(cartId);
  return <CartBadge count={cart.items.length} />; // a hole
}

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <main>
      <ProductInfo id={id} />
      <Suspense fallback={<CartBadge count={0} />}>
        <Cart />
      </Suspense>
    </main>
  );
}
```

### A correction to my 2025 example

In that post, my PPR example was a client component that fetches data in `useEffect`, wrapped in `<Suspense>`. That was not PPR. A client component with `useEffect` doesn't suspend rendering: it goes into the shell and fetches data only after hydration in the browser. It was plain CSR inside a static shell. The dynamic part of PPR is a server component, like `Cart` above.

* * *

## Client-Side Rendering (CSR)

This is the one section where almost nothing changed. A `"use client"` component that fetches data in the browser works in 16 the same way it did in 14. Only its surroundings changed: the rest of the page around it is now a static shell or a dynamic hole on the server.

That is why CSR has a narrower use today than in 2025. It makes sense for data that changes while the user is watching (a search box with suggestions, a live chart) or that depends on something only the browser knows (`localStorage`, for example). Data that is ready at request time is better fetched in a server component in `<Suspense>`: the user gets HTML without waiting for JavaScript.

* * *

## react.cache() vs "use cache"

**In Next.js 14** `react.cache()` was the way to deduplicate: two components asking for the same user in one request made one query instead of two.

**In Next.js 16** `react.cache()` does exactly the same and is still useful. Just remember these are two different tools:

<table><thead><tr><th></th><th>react.cache()</th><th>"use cache"</th></tr></thead><tbody><tr><td>How long it lives</td><td>one request</td><td>per cacheLife, across requests</td></tr><tr><td>Who sees the result</td><td>only this request</td><td>every user</td></tr><tr><td>Can read cookies/session</td><td>yes</td><td>no (unless "use cache: private")</td></tr><tr><td>What for</td><td>deduplicating user data</td><td>data shared by everyone</td></tr></tbody></table>

```tsx
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";

export const getCurrentUser = cache(async () => {
  const session = (await cookies()).get("session")?.value;
  return db.user.findBySession(session); // straight to the database
});
```

One more correction to 2025: that example fetched the user with `fetch` to its own `/api/user` from a server component. That is a pointless HTTP hop to yourself. A server component can query the database or service directly, as above.

* * *

## Manual revalidation

**In Next.js 14** you tagged a `fetch` and invalidated the tag with one function, no matter who called it:

```tsx
const res = await fetch(url, { next: { tags: ["posts"] } });

// anywhere: a Server Action or a Route Handler
revalidateTag("posts");
```

**In Next.js 16** you set the tag inside a `"use cache"` scope with `cacheTag`, and invalidation splits into two cases.

**An editor clicked "Save" in your app (Server Action).** You use `updateTag`: the tag expires and the next render waits for fresh data. The editor sees their change right away.

```tsx
"use server";
import { updateTag } from "next/cache";

export async function savePost(formData: FormData) {
  await db.post.update(/* ... */);
  updateTag("posts");
}
```

**A headless CMS sent a webhook (Route Handler).** `updateTag` only works in Server Actions, so here you use `revalidateTag` with a profile:

```typescript
import { revalidateTag } from "next/cache";

export async function POST(request: Request) {
  // verify the webhook signature first
  revalidateTag("posts", "max");
  return Response.json({ ok: true });
}
```

With `"max"` this is stale-while-revalidate: the first request after the webhook still gets the old content and triggers the refresh. In the demo, the first reload after the webhook showed the old content and the new one appeared on the second. If an editor publishes in the CMS and refreshes the page right away, they will see the old version once. Tell them before they report a bug.

`revalidatePath` still works when you want to refresh a specific URL rather than a tag.

* * *

## What changes in production?

Everything above works with `next start` on a laptop. Production adds two things you don't see locally.

**The default cache lives in the instance's memory.** What was rendered at build time sits in the static shell and is safe. A `"use cache"` entry created at runtime (a dynamic path, a revalidation) lives in the memory of the instance that computed it. On serverless, the next request may hit a cold instance and compute everything again. `"use cache: remote"` moves the entry to a shared cache, but every hit is then a network request, so it only pays off with a high hit rate.

**A deploy only carries what was created at build time.** Pages rendered during the build (paths from `generateStaticParams`, for example) are part of the deploy and are served fast right away. In the demo, the first request to such a page took 34 ms locally, the second 3 ms, and the logs showed no data being recomputed. What you lose are entries created at runtime: new paths outside `generateStaticParams` and everything recomputed after a revalidation. Cache keys include the build ID, so after a deploy those entries are computed again, and the first visitors pay for it.

* * *

## Which strategy to pick in 2026?

<table><thead><tr><th>Use case</th><th>Next.js 14</th><th>Next.js 16</th></tr></thead><tbody><tr><td>About page, terms</td><td>force-static</td><td>read nothing from the request; ensureStatic in the layout</td></tr><tr><td>Blog, docs from a CMS</td><td>ISR, revalidate</td><td>"use cache" + cacheTag + cacheLife("max") + webhook</td></tr><tr><td>Product catalog</td><td>ISR, revalidate: 3600</td><td>"use cache" + cacheLife("hours")</td></tr><tr><td>Product page with a cart</td><td>PPR (experimental)</td><td>shell with the product + cart in &lt;Suspense&gt;</td></tr><tr><td>User dashboard</td><td>force-dynamic</td><td>dynamic parts in &lt;Suspense&gt;, react.cache()</td></tr><tr><td>Search with suggestions</td><td>CSR</td><td>CSR, no change</td></tr></tbody></table>

### Full comparison

<table><thead><tr><th>Strategy</th><th>Data freshness</th><th>Performance</th><th>SEO</th><th>Cache control in 16</th></tr></thead><tbody><tr><td>Static shell</td><td>from the build</td><td>highest</td><td>yes</td><td>ensureStatic</td></tr><tr><td>"use cache" (formerly ISR)</td><td>per cacheLife</td><td>high</td><td>yes</td><td>cacheLife, cacheTag</td></tr><tr><td>Dynamic hole (formerly SSR)</td><td>on every request</td><td>depends on the source</td><td>yes</td><td>&lt;Suspense&gt;, connection()</td></tr><tr><td>CSR</td><td>in the browser</td><td>depends on the client</td><td>weak</td><td>outside Next.js</td></tr><tr><td>react.cache()</td><td>one request</td><td>deduplication</td><td>n/a</td><td>n/a</td></tr></tbody></table>

![Decision flowchart: which caching strategy to pick in Next.js 16](https://assets.cmssy.io/6ae4795c65113d877fc8d877/1531978b-f49a-451a-ba8c-22a473bf99d3-nextjs-cache-2026-03-which-strategy-en.png)

### Quick rules for 2026

1.  Every `"use cache"` gets an explicit `cacheLife`.
2.  Don't nest a shorter-lived cache inside a longer one. Put them side by side.
3.  CMS content: `cacheTag` + `max` + a webhook calling `revalidateTag(tag, "max")`. Keep `updateTag` for Server Actions.
4.  A section that must stay static gets `ensureStatic = "navigation"` in its layout.
5.  Check caching on a production build and on the platform you deploy to, not in `next dev`. Look at the Revalidate column in the `next build` output too.

Next.js 17 is expected to make Cache Components the only model. If you migrate now, you do it once.