# Mastering Caching and Rendering in Next.js

> Learn how caching and rendering strategies in Next.js 14 impact performance and scalability - with real examples and code for SSG, ISR, SSR, CSR & PPR.

- URL: https://www.maciejbednarczyk.com/en/blog/mastering-caching-and-rendering-in-next-js
- Author: Maciej Bednarczyk
- Language: en
- Published: 2025-05-02

## Introduction

Building fast, scalable, and reliable web applications is no longer a luxury - it’s the baseline. Users expect instant load times, real-time content, and seamless interaction. As developers, we’re constantly balancing **performance, freshness, and server cost**.

**This is where Next.js shines.**

Next.js is not just a React framework - it’s an advanced platform that gives you multiple ways to control **how data is fetched, rendered, and cached**. With the introduction of the **App Router**, caching and rendering have evolved dramatically.

But here’s the challenge:

> ❓ *Should I pre-render this page statically, or render it dynamically on every request?*

> ❓ *How can I cache expensive fetches without making data stale?*

> ❓ *What is ISR? Is it just a buzzword?*

If these questions sound familiar, you’re in the right place.

## What is Caching in Next.js?

![Caching struggle](https://assets.cmssy.io/6ae4795c65113d877fc8d877/5a62f871-96f5-46aa-9545-7bd665d73834-e96063f6ce0b.png)

Caching is temporarily storing data or content, so it can be reused instead of being recomputed or re-fetched every time. In web development, caching is key to **speed, scalability, and resilience** - and in Next.js, it’s deeply integrated into how your app is built and served.

Think of caching like a **smart memory**:

If you’ve already done something once (like fetching data or rendering HTML), why do it again?

Instead of fetching fresh data on every request or rendering every page from scratch, you can **store the result** (HTML, JSON, API response, function output) and reuse it.

**This avoids:**

-   Repeating expensive database/API calls
-   Re-rendering the same output again and again
-   Delaying the user experience

## Why Do We Need Caching?

Caching ensures:

-   ⚡️ **Faster responses** (no unnecessary fetches/rendering)
-   📈 **Lower server load** (reuse responses across multiple requests)
-   🔄 **Efficient data freshness** (controlled updates/revalidation)
-   🚀 **Enhanced scalability** (cached data serves thousands)

## Rendering Patterns Explained

Rendering is about one simple question:

> “**When and where should we generate the HTML for this page?**”

Next.js gives you full control over that answer - page by page, component by component. Each rendering pattern in Next.js has trade-offs in **speed, freshness, and scalability**, and knowing when to use which makes all the difference in building fast and maintainable applications.

### Static Site Generation (SSG)

-   HTML is generated **once at build time**.
-   Served instantly from the file system or edge cache.
-   **Blazing fast** and ideal for content that doesn’t change often.

#### SSG - How does it work?

When you run:

```bash
next build
```

Next.js:

1.  Calls your server functions (e.g. fetch users)
2.  Generates static HTML + JSON
3.  Saves it to disk or edge cache

#### SSG - Example

```tsx
// app/ssg-example/page.tsx
export const dynamic = "force-static"; // ensure it's cached at build time

export default async function Page() {
  const users = await getServerUserList(); // this runs at build!
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(u => <li key={u.id}>{u.name}</li>)}
      </ul>
    </div>
  );
}
```

🟡 **Important**: If your getServerUserList() fetches from a live API, remember that the data will not update unless you re-build the app.  

All users get **the same version** of the page, and it loads **super fast**.

#### SSG - Things to keep in mind

-   If your data **needs to change**, SSG **won’t show updates** unless you re-deploy.
-   Good for **read-only** content.
-   Use force-static to **explicitly opt into SSG** in the App Router.

* * *

### Server-Side Rendering (SSR)

-   HTML is generated **on every request**, on the server.
-   Page data is always fresh - but slower and more expensive.
-   Best for highly dynamic or user-specific content.

#### SSR - How does it work?

When a user visits your page:

1.  Next.js runs your server function (e.g. fetch users)
2.  Generates HTML on the fly
3.  Sends the response to the browser

This happens **every single time** the page is requested.

#### SSR - Example

```tsx
// app/ssr-example/page.tsx
export const dynamic = "force-dynamic"; // always render on the server

export default async function Page() {
  const users = await getServerUserList(); // runs on every request
  const now = new Date().toLocaleTimeString();

  return (
    <div>
      <h1>Live Users (SSR)</h1>
      <p>Generated at: {now}</p>
      <ul>
        {users.map(u => <li key={u.id}>{u.name}</li>)}
      </ul>
    </div>
  );
}
```

Every time you refresh the page, it re-fetches the data and regenerates the HTML.

#### SSR - Things to keep in mind

-   SSR can **slow down under heavy traffic**, since each request requires a full render.
-   Great for dynamic content, but use it **only when necessary**.
-   If the data can be cached or doesn’t change per user, consider ISR or SSG instead.

* * *

### Incremental Static Regeneration (ISR)

-   HTML is pre-rendered like SSG, but **can be updated after deployment**.
-   Pages are cached and **revalidated periodically** or on demand.
-   Perfect balance between **speed and freshness**.

#### ISR - How does ISR work?

1.  At **build time**, Next.js pre-renders selected pages into static HTML.
2.  These pages are served instantly from cache.
3.  After a time (e.g. 60 seconds), if a new request comes in:
    -   Next.js will re-generate the page **in the background**
    -   New version replaces the old cache
    -   Future users get the fresh version  
        

So you get:

-   **Fast first loads** ✅
-   **Auto-updated content** ✅
-   **Low server load** ✅

#### ISR - Example without *generateStaticParams*

```tsx
// app/isr-no-params/page.tsx
export const revalidate = 60; // refresh this page every 60 seconds

export default async function Page() {
  const posts = await getPostList();

  return (
    <div>
      <h1>Latest Blog Posts</h1>
      <ul>
        {posts.map(p => <li key={p.id}>{p.title}</li>)}
      </ul>
    </div>
  );
}
```

-   First request: page is generated and cached.
-   After 60s: the next request will **re-generate** the page.

#### ISR - Example with *generateStaticParams*

```tsx
export const revalidate = 60;
export const dynamicParams = true;

export async function generateStaticParams() {
  const posts = await getPostList();
  return posts.slice(0, 5).map(post => ({ id: post.id }));
}
```

-   First 5 pages are generated at build.
-   Others are created **on-demand** when a user visits them.
-   All pages are cached and auto-refreshed after 60s.

#### ISR - What does *dynamicParams* mean?

<table><thead><tr><th>Setting</th><th>Behavior</th></tr></thead><tbody><tr><td>dynamicParams: true</td><td>If the page was not pre-built, it will be rendered dynamically on request</td></tr><tr><td>dynamicParams: false</td><td>If the page wasn’t generated during build, it will return 404</td></tr></tbody></table>

This gives you **control over what’s static and what’s dynamic**.

#### ISR - One Small revalidate Can Break Your Whole Page Caching ⚠️

Let’s say you’re building a documentation site, and you want each page to revalidate only once a week:

```tsx
export const revalidate = 60 * 60 * 24 * 7; // 7 days
```

Everything looks good. Pages are static, fast, and cost-effective.

Later, you add a **system status bar** to your layout that fetches API data every 60 seconds:

```tsx
await fetch("https://status.example.com/api", {
  next: { revalidate: 60 }, // 1 minute
});
```

Now, **every doc page revalidates every 1 minute** - not 7 days.

> 🔥 **Next.js uses the shortest revalidate value found anywhere in the render tree**

> That means layouts and deeply nested components can unintentionally lower the entire page’s cache lifetime.

**💡 How to Avoid This**

-   Don’t put short-revalidate fetches in **layouts** or **root components**
-   If you need live data like status, consider:
    -   Client-side rendering (CSR)
    -   Using Suspense + a dynamic boundary
    -   Moving the fetch to a useEffect if it’s purely visual

**🧪 How to Detect It**

Next.js won’t warn you about this.

To find it:

-   Open the browser’s **Network tab**
-   Inspect page response headers:
    -   age – how long the page has been cached
    -   cache-control – how long it’s allowed to stay cached
-   If the age is always low → you’re revalidating more than you think

```
RootLayout.tsx
├── Header.tsx → fetch(..., { revalidate: 60 }) ❗
└── Page.tsx   → revalidate = 604800 (7 days)
```

![ISR diagram](https://assets.cmssy.io/6ae4795c65113d877fc8d877/4d2aff37-ceca-45ae-a149-9166f8e6a4a0-97e2662bc9bd.png)

#### ISR - Things to keep in mind

-   Avoid mixing components with very different revalidate times
-   Don’t put live fetches into global layouts
-   Use **client-side rendering** (useEffect) or **suspense boundaries** for small dynamic parts
-   Audit your cache behavior regularly (e.g. in Vercel’s observability)

* * *

### Partial Pre-Rendering (PPR)

-   Part of the page is **static**, and part is **dynamic**.
-   Uses **React Server Components** + Suspense.
-   Great for layouts that should load instantly, while dynamic content streams in.

**Status in Next.js 16:** the old experimental PPR flags are gone. PPR now comes with Cache Components: enable `cacheComponents: true` in `next.config`, and data is dynamic by default until you mark it with `"use cache"`. With it on, `"use cache"` and `cacheLife` replace route segment options like `dynamic` and `revalidate`.

It allows you to **pre-render only part of the page**, while other parts are **fetched and rendered dynamically on the server or client**.  

The goal?

> Combine **static speed** with **dynamic flexibility** - on the same page.

#### PPR- How does it work?

PPR uses:

-   **React Server Components (RSC)** - for server-rendered static/dynamic content
-   **<Suspense>** - to separate parts of the page into dynamic units
-   **"use cache"** - to mark what belongs in the static shell
-   Optional **client components** - for real-time or interactive data

Next.js **streams the HTML**:

1.  Static parts appear immediately
2.  Dynamic parts stream in as they load
3.  All this happens within a single page request

#### PPR - Example

**Page layout (mixed rendering)**

```tsx
// next.config.ts - enable PPR with: cacheComponents: true
// app/ppr-example/page.tsx

import { getPostList } from "@/services/server";
import { Suspense } from "react";
import DynamicUserBox from "./_components/dynamic-user-box";

export default async function Page() {
  "use cache"; // part of the static shell
  const posts = await getPostList();
  const generatedAt = new Date().toLocaleString();

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold">📚 Blog + Live Data (PPR)</h1>

      <p className="text-gray-500">Static part generated at: {generatedAt}</p>

      <section className="mt-6">
        <h2 className="text-xl font-semibold">📄 Static Blog List (SSG)</h2>
        <ul className="list-disc pl-5">
          {posts.map((p) => (
            <li key={p.id}>{p.title}</li>
          ))}
        </ul>
      </section>

      <hr className="my-6" />

      <section>
        <h2 className="text-xl font-semibold">⚡ Live User Data</h2>
        <Suspense fallback={<p>Loading users…</p>}>
          <DynamicUserBox />
        </Suspense>
      </section>
    </main>
  );
}
```

**The dynamic client component**

```tsx
// app/ppr-example/_components/DynamicUserBox.tsx

"use client";

import { useEffect, useState } from "react";
import { getClientUserList } from "@/services/client";
import { User } from "@/types";

export default function DynamicUserBox() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    getClientUserList().then((data) => {
      setUsers(data);
      setLoading(false);
    });
  }, []);

  if (loading) return <p className="text-blue-500">Fetching users…</p>;

  return (
    <div className="p-4 bg-gray-100 rounded">
      <h3 className="font-medium mb-2">👥 Active Users</h3>
      <ul className="list-disc pl-5">
        {users.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>
    </div>
  );
}
```

![PPR map](https://assets.cmssy.io/6ae4795c65113d877fc8d877/3cc0670f-4e13-4148-9f18-d69bc4f26650-18fda663229c.png)

#### PPR - Things to keep in mind

-   Use PPR when you want to **keep layout and main content fast**
-   Wrap dynamic sections in <Suspense> to stream them separately
-   Use "use client" components for data that changes **per user or in real-time**
-   Keep dynamic parts **small** - don’t delay the entire page

* * *

### Client-Side Rendering (CSR)

-   HTML is mostly static; the **data is fetched in the browser**.
-   Fully dynamic, great for interactivity.
-   But slower initial load and SEO-unfriendly.

#### CSR - How does it work?

1.  Next.js sends a basic HTML shell to the browser.
2.  React loads and hydrates the components.
3.  Then your components **fetch data on the client**, using fetch() or useEffect().

This means:

-   Nothing is fetched or rendered on the server
-   You have **maximum flexibility**, but the page may load slower initially

#### CSR - Example

```tsx
// app/csr-example/page.tsx

import { UserClientList } from "./_components/user-client-list";

export default function Page() {
  const now = new Date().toLocaleString();

  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold mb-4">👤 Client-Side Rendering</h1>
      <p className="text-gray-500 mb-2">Page loaded at: {now}</p>
      <UserClientList />
    </div>
  );
}
```

```tsx
// app/csr-example/_components/user-client-list.tsx

"use client";

import { useEffect, useState } from "react";
import { getClientUserList } from "@/services/client";
import { User } from "@/types";

export const UserClientList = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    getClientUserList().then((data) => {
      setUsers(data);
      setLoading(false);
    });
  }, []);

  if (loading) return <p>Loading users...</p>;

  return (
    <ul className="list-disc pl-5">
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
};
```

#### CSR - Things to keep in mind

-   Use CSR for **non-indexable**, **user-specific** pages
-   Show loading states clearly
-   Prefetch critical data if possible
-   Keep components lightweight to reduce hydration time

* * *

### Function-Level Caching in Next.js ⚛️ *react.cache()*

Next.js (thanks to React Server Components) allows you to cache the **result of any async function**, using react.cache().

This is called **function-level caching** - and it’s super useful for **avoiding duplicated fetches or expensive calculations** during server rendering.

**✅ When should you use it?**

Use `react.cache()` when:

-   You want to **deduplicate fetch calls** within the same request
-   You’re calling the same function **multiple times in one render tree**
-   You want **fine-grained control** without relying on full-page revalidation  
    

It works best in:

-   React Server Components
-   Server-only functions
-   Situations with internal caching needs

#### How does it work?

The first time you call the function with a given input, it runs and returns the result.

Subsequent calls (within the same request lifecycle) return the **cached value**, without re-running the logic.

This can save **time, cost, and bandwidth** - especially for expensive DB/API queries.

#### Example

```typescript
// utils/getServerUser.ts
import "server-only";

import { cache } from "react";

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

// This function will cache its result during a single request
export const getServerCurrentUser = cache(async () => {
  console.log("🧠 Fetching current user...");
  const res = await fetch(`${baseUrl}/api/user`);
  if (!res.ok) throw new Error("Failed to fetch user");
  return res.json();
});
```

```tsx
// app/cache-example/page.tsx

import { getServerCurrentUser } from "@/utils/getServerUser";
import { UserInfo } from "./_components/user-info";

export default async function Page() {
  // These will both return the same cached result (no second fetch)
  const user1 = await getServerCurrentUser();
  const user2 = await getServerCurrentUser();

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">🧠 Function-Level Caching</h1>
      <p className="text-gray-500 mb-4">
        Fetching same user twice – only the first call actually runs.
      </p>
      <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
        <UserInfo title="Left Column" user={user1} />
        <UserInfo title="Right Column" user={user2} />
      </div>
    </div>
  );
}
```

**⚠️ Important Notes**

-   react.cache() only caches **within the same request** - it’s not global or cross-request.
-   For persistent caching (e.g. across requests), use **next: { revalidate }** or **edge/CDN caching**.
-   You must call it in a **server-only context** (not inside client components).
-   This works well with **React Server Components**, especially when using the App Router.

#### Best Practices

-   Wrap **frequently used server functions** with cache(...)
-   Combine with revalidateTag() or revalidatePath() for smart invalidation
-   Avoid overusing it for functions with high variability or user-specific data

* * *

### Manual Revalidation in Next.js

While revalidate lets you define when a page or fetch should refresh automatically, **manual revalidation** gives you **full control over when content gets updated** - instantly, on demand.

This is perfect for:

-   Admin panels (“Refresh posts now”)
-   CMS integrations (“Update this path”)
-   Invalidating cache after form submissions
-   Saving costs by avoiding unnecessary revalidation

**Two key APIs for on-demand revalidation**

<table><thead><tr><th>Function</th><th>Use case</th><th>Level</th></tr></thead><tbody><tr><td>revalidateTag("...", "max")</td><td>Invalidate all fetches using this tag</td><td>Fetch-level</td></tr><tr><td>revalidatePath("/...")</td><td>Invalidate cached page for a specific path</td><td>Page-level</td></tr></tbody></table>

#### revalidateTag() Example

You can tag your fetch requests like this:

```typescript
await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] },
});
```

Then, in your server action or route:

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

export async function POST() {
  revalidateTag("posts", "max");
  return new Response("Tag revalidated");
}
```

💡 This marks **any cached data fetched with that tag** as stale: the next visit gets the old version while fresh data loads in the background - great for updating parts of the page without rebuilding. In Next.js 16 the single-argument form is deprecated (`"max"` is the recommended profile). For immediate expiry use `updateTag()` in Server Actions, or `{ expire: 0 }` for webhooks.

#### revalidatePath() Example

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

revalidatePath("/blog");
revalidatePath(`/product/${id}`);
```

This is useful when you want to regenerate the entire page instead of specific data.

#### Real-world example (admin-style refresh button)

```tsx
// app/revalidate-on-demand/page.tsx

import { cache } from "react";
import { updateTag } from "next/cache";
import { User } from "@/types";

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";

const fetchUsers = cache(async () => {
  const res = await fetch(`${baseUrl}/api/users`, {
    next: { tags: ["users"] },
  });
  return res.json();
});

export default async function Page() {
  const users: User[] = await fetchUsers();

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold mb-4">🔁 Revalidate Tag Example</h1>

      <form
        action={async () => {
          "use server";
          updateTag("users");
        }}
      >
        <button className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
          🔄 Refresh Users
        </button>
      </form>

      <ul className="mt-6 pl-5 list-disc">
        {users.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>
    </main>
  );
}
```

#### API Route version

Sometimes, it’s better to revalidate from an external action (e.g. webhook, CMS, or button).

```typescript
// app/api/revalidate-users/route.ts

import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

export async function POST() {
  try {
    revalidateTag("users", "max");
    return NextResponse.json({ revalidated: true });
  } catch (e) {
    return NextResponse.json({ error: "Failed" }, { status: 500 });
  }
}
```

Then call it from the client or CMS webhook!

#### Best Practices

-   Use revalidateTag() for **data refresh**, revalidatePath() for **full-page updates**
-   Prefer server actions when possible - simple and fast
-   Combine with cache() to control expensive fetches
-   Monitor with DevTools or Vercel headers to verify cache invalidation

* * *

## Rendering & Caching Strategy Decision Guide

<table><thead><tr><th>Use case</th><th>Recommended Strategy</th></tr></thead><tbody><tr><td>💾 Static content, rarely changes</td><td>Static Site Generation (SSG)</td></tr><tr><td>📰 Content changes sometimes, but must be fast</td><td>Incremental Static Regeneration (ISR)</td></tr><tr><td>📊 Live or per-user data (e.g. dashboard)</td><td>Server-Side Rendering (SSR)</td></tr><tr><td>🧩 Part of the page is dynamic (e.g. sidebar)</td><td>Partial Pre-Rendering (PPR)</td></tr><tr><td>🙋♂️ Logged-in user experience, no SEO needed</td><td>Client-Side Rendering (CSR)</td></tr><tr><td>⚡ Prevent duplicate fetches within one request</td><td>Function-level caching with react.cache()</td></tr><tr><td>🔄 Trigger updates manually (e.g. via CMS)</td><td>revalidateTag() / revalidatePath()</td></tr></tbody></table>

* * *

## Full Comparison Table

<table><thead><tr><th>Strategy</th><th>Data Freshness</th><th>Performance</th><th>SEO Friendly</th><th>Cache Control</th></tr></thead><tbody><tr><td>SSG</td><td>❌ Static only</td><td>⚡⚡⚡ Very fast</td><td>✅ Yes</td><td>Built once at deploy</td></tr><tr><td>ISR</td><td>🟡 Periodic/on-demand</td><td>⚡⚡ Fast</td><td>✅ Yes</td><td>revalidate or tags</td></tr><tr><td>SSR</td><td>✅ Always fresh</td><td>⚠️ Slower</td><td>✅ Yes</td><td>No caching (per request)</td></tr><tr><td>PPR</td><td>🔀 Mixed</td><td>⚡ Fast + Flex</td><td>✅ Yes</td><td>Static + Suspense</td></tr><tr><td>CSR</td><td>✅ Always fresh</td><td>🐢 Initial load</td><td>❌ No</td><td>Browser-only</td></tr><tr><td>react.cache()</td><td>♻️ Per-request reuse</td><td>⚡ Faster renders</td><td>✅ Yes</td><td>Code-level deduplication</td></tr></tbody></table>

* * *

## Quick Rules to Remember

-   **Always default to static** (SSG or ISR) unless you need real-time content
-   Use **revalidateTag()** to update selectively, without overfetching
-   Don’t mix fast-revalidating fetches inside global layouts - it can silently break your caching
-   Combine strategies: **Cache Components + Suspense = PPR**, **SSR + react.cache() = Efficient SSR**
-   Watch response headers (age, x-vercel-cache) to debug caching in production

* * *

## NEXT.js Rendering & Caching Decision Guide

![decision guide infographic](https://assets.cmssy.io/6ae4795c65113d877fc8d877/46033998-c786-4503-85b7-166b2a951a52-495ef05e69cd.png)

## Final Advice

> Use caching not just to make things faster - use it to build smarter, cheaper, and more scalable apps. Next.js gives you the toolbox - **you decide how to combine the pieces**.