MB
๐Ÿš€ Mastering Caching and Rendering in Next.js

๐Ÿš€ Mastering Caching and Rendering in Next.js

May 2, 2025

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.

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
Caching struggle

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:

sh

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?

SettingBehavior
dynamicParams: trueIf the page was not pre-built, it will be rendered dynamically on request
dynamicParams: falseIf the page wasnโ€™t generated during build, it will return 404

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
ISR diagram


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.


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
  • 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

// app/ppr-example/page.tsx

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

export const revalidate = 0; // this page is dynamic

export default async function Page() {
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
PPR map

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 18) 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
"use server";

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

FunctionUse caseLevel
revalidateTag("...")Invalidate all fetches using this tagFetch-level
revalidatePath("/...")Invalidate cached page for a specific pathPage-level


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");
return new Response("Tag revalidated");
}

๐Ÿ’ก This will invalidate any cached data fetched with that tag - great for updating parts of the page without rebuilding.


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 { 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";
revalidateTag("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");
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

Use caseRecommended Strategy
๐Ÿ’พ Static content, rarely changesStatic Site Generation (SSG)
๐Ÿ“ฐ Content changes sometimes, but must be fastIncremental Static Regeneration (ISR)
๐Ÿ“Š Live or per-user data (e.g. dashboard)Server-Side Rendering (SSR)
๐Ÿงฉ Part of the page is dynamic (e.g. sidebar)Partial Pre-Rendering (PPR)
๐Ÿ™‹โ™‚๏ธ Logged-in user experience, no SEO neededClient-Side Rendering (CSR)
โšก Prevent duplicate fetches within one requestFunction-level caching with react.cache()
๐Ÿ”„ Trigger updates manually (e.g. via CMS)revalidateTag() / revalidatePath()

Full Comparison Table

StrategyData FreshnessPerformanceSEO Friendly Cache Control
SSGโŒ Static onlyโšกโšกโšก Very fastโœ… YesBuilt once at deploy
ISR๐ŸŸก Periodic/on-demandโšกโšก Fastโœ… Yesrevalidate or tags
SSRโœ… Always freshโš ๏ธ Slowerโœ… YesNo caching (per request)
PPR๐Ÿ”€ Mixedโšก Fast + Flexโœ… YesStatic + Suspense
CSRโœ… Always fresh๐Ÿข Initial loadโŒ NoBrowser-only
react.cache()โ™ป๏ธ Per-request reuseโšก Faster rendersโœ… YesCode-level deduplication

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: SSG + 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
decision guide infographic

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.