Rendering in Next.js 🎨

Introduction 👋

Rendering is the process of turning your React code into HTML that browsers can display. Next.js offers a spectrum of rendering strategies — from fully static to fully dynamic — and this flexibility is one of its biggest strengths. This tutorial covers how rendering works under the hood, and how to choose the right strategy for each part of your app.

Information

Understanding rendering is essential for making informed decisions about performance, SEO, and data freshness.

What is Rendering? 🧠

Rendering converts your React components into HTML markup. In Next.js, this can happen at build time, on the server per-request, or in the browser — and often a mix of all three within a single page.

  • Where rendering happens: server or client.
  • When rendering happens: build time, request time, or after a cache expires.

Rendering Pipeline 🏗️

A typical Next.js request flows through several stages: matching a route, rendering Server Components into a special data format (RSC Payload), converting that into HTML, sending HTML to the browser, and finally hydrating it into an interactive app.

Request
Route Match
Server Render
HTML + RSC Payload
Hydration

Client-Side Rendering (CSR) 💻

In Client-Side Rendering, the browser downloads a minimal HTML shell and JavaScript bundle, then renders the UI entirely in the browser. This is the traditional React SPA model.

components/Counter.tsx

"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Caution

Pure CSR can hurt SEO and initial load performance, since content isn't present in the initial HTML.

Server-Side Rendering (SSR) 🖥️

Server-Side Rendering generates the full HTML for a page on the server, on every request. This ensures content is always fresh and immediately visible, at the cost of a server round-trip per visit.

app/dashboard/page.tsx

export const dynamic = "force-dynamic";

export default async function Dashboard() {
  const data = await fetchLiveData();
  return <p>{data.value}</p>;
}

Static Site Generation (SSG) 📄

With Static Site Generation, pages are rendered to HTML once at build time and reused for every request, making them extremely fast to serve since no per-request computation is needed.

app/blog/[slug]/page.tsx

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

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return <h1>{post.title}</h1>;
}

Tip

Statically generated pages MUST be served instantly from a CDN, with no server compute required at request time.

Incremental Static Regeneration (ISR) ♻️

ISR lets you update statically generated pages after build time, on a set interval, without rebuilding the entire site. It combines the speed of static generation with the freshness of dynamic data.

app/products/[id]/page.tsx

export const revalidate = 3600; // regenerate every hour

export default async function Product({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  return <h1>{product.name}</h1>;
}

Partial Prerendering (PPR) 🧩

Partial Prerendering is an experimental strategy that combines a static shell, rendered at build time, with dynamic holes that stream in at request time — giving both instant static content and fresh dynamic data in a single page.

app/product/[id]/page.tsx

import { Suspense } from "react";

export const experimental_ppr = true;

export default function Product({ params }: { params: { id: string } }) {
  return (
    <div>
      <StaticProductInfo />
      <Suspense fallback={<p>Loading reviews…</p>}>
        <DynamicReviews id={params.id} />
      </Suspense>
    </div>
  );
}

Note

PPR relies on <Suspense> boundaries to determine which parts of a page are static versus dynamic.

Dynamic Rendering 🔄

Dynamic Rendering renders a route on the server for every incoming request, using the latest data. Next.js automatically switches a route to dynamic rendering when it detects request-specific APIs.

  1. Using cookies() or headers().
  2. Reading the searchParams prop.
  3. Setting export const dynamic = "force-dynamic".
  4. Using an uncached fetch() request.

Hybrid Rendering 🧬

Next.js apps are typically hybrid by nature — some routes are static, others dynamic, and some combine both through PPR or nested <Suspense> boundaries, all within the same application.

RouteStrategy
/Static (SSG)
/blog/[slug]Static + ISR
/dashboardDynamic (SSR)

Streaming 🌊

Streaming allows the server to send HTML to the browser incrementally, as it becomes ready, rather than waiting for the entire page to finish rendering. This is built on <Suspense> boundaries.

app/dashboard/page.tsx

import { Suspense } from "react";

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading stats…</p>}>
        <Stats />
      </Suspense>
    </div>
  );
}

Best Practice

Streaming lets users see and interact with fast parts of a page immediately, while slower data continues loading.

Hydration 💧

Hydration is the process where React attaches event listeners and internal state to the server-rendered HTML in the browser, turning static markup into a fully interactive application.

Server
Render HTML
Send to Browser
Hydrate & Attach Events

Warning

A hydration mismatch occurs when server-rendered HTML doesn't match what the client renders — often caused by using browser-only APIs during render.

React Server Components 🧠

React Server Components (RSC) render exclusively on the server and never ship their JavaScript to the browser, resulting in smaller client bundles. They can directly access backend resources like databases without an API layer.

app/posts/page.tsx

export default async function Posts() {
  const posts = await db.query("SELECT * FROM posts");
  return <List type="unordered">{posts.map((p) => <List.Item key={p.id}>{p.title}</List.Item>)}</List>;
}

Client Components 🖱️

Client Components, marked with the "use client" directive, render on the server for the initial HTML and then hydrate in the browser to support interactivity like state, effects, and event handlers.

components/LikeButton.tsx

"use client";

import { useState } from "react";

export default function LikeButton() {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(!liked)}>{liked ? "❤️" : "🤍"}</button>;
}

Server Components 🗄️

Server Components are the default in the App Router — every component is a Server Component unless explicitly marked with "use client". They're ideal for data fetching and content that doesn't require interactivity.

CapabilityServerClient
Direct database access
useState / useEffect
Event handlers (onClick)
Zero client JS shipped

Rendering Strategies 🗺️

Choosing a rendering strategy is about matching how often data changes with how it's rendered. Next.js supports mixing strategies at the individual route segment level.

  1. Static — content rarely changes (marketing pages, docs).
  2. ISR — content changes occasionally (product catalogs, blogs).
  3. Dynamic — content changes per-request or per-user (dashboards, feeds).

Static Rendering 🧊

Static Rendering is the default in the App Router. As long as a route doesn't use dynamic APIs, Next.js renders it once at build time and serves the cached result to every visitor.

app/about/page.tsx

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

Dynamic Rendering Triggers ⚡

Certain APIs and configurations automatically opt a route out of static rendering. Recognizing these triggers helps you understand why a route you expected to be static is actually dynamic.

TriggerEffect
cookies()forces dynamic rendering
headers()forces dynamic rendering
searchParams propforces dynamic rendering
uncached fetch()forces dynamic rendering

Route Segment Configuration ⚙️

Exported configuration constants let you explicitly control a route segment's rendering behavior, overriding Next.js's automatic detection.

app/page.tsx

export const dynamic = "force-static"; // or "force-dynamic", "auto", "error"
export const revalidate = 3600;

Rendering Performance 🚀

  • Prefer static rendering wherever content doesn't need to change per-request.
  • Use <Suspense> to stream slow data instead of blocking the whole page.
  • Push interactivity down into small Client Components rather than marking entire trees as client-side.
  • Cache fetch() calls where data doesn't need to be request-fresh.

SEO Considerations 🔍

Because Next.js renders HTML on the server (via SSR, SSG, or ISR), content is present in the initial response, making it fully crawlable by search engines — unlike pure client-side rendered apps.

Tip

For SEO-critical pages, avoid deferring essential content behind client-only rendering — keep it in Server Components.

Caching and Rendering 🗃️

Rendering and caching are closely linked: statically rendered pages are cached at the data cache and full route cache layers, while dynamic pages typically bypass these caches unless explicitly configured.

Cache LayerWhat it Stores
Request Memoizationdeduplicated fetch() calls within a single render
Data Cachepersisted fetch() results across requests
Full Route Cacherendered HTML and RSC payload for static routes
Router Cacheclient-side cache of visited route segments

Revalidation 🔁

Revalidation refreshes cached data or pages without a full rebuild. Next.js supports both time-based revalidation and on-demand revalidation triggered by code.

app/actions.ts

"use server";

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

export async function updatePost() {
  await savePost();
  revalidatePath("/blog");
  revalidateTag("posts");
}

Rendering Debugging 🐞

Next.js provides build-time output indicating whether each route is (static), ƒ (dynamic), or incremental — a quick way to verify a route is rendering the way you expect.

Hint

Unexpectedly dynamic routes are almost always caused by an overlooked cookies(), headers(), or uncached fetch() call somewhere in the tree.

Choosing the Right Rendering Strategy 🧭

Content rarely changes and is identical for every visitor — a perfect fit for Static Rendering with no revalidate needed.

Content updates occasionally when new posts are published — ISR with a sensible revalidate window keeps pages fast and reasonably fresh.

Content is user-specific and must always be current — Dynamic Rendering ensures every request reflects the latest state.

Best Practices ✅

  • Default to Server Components; add "use client" only where interactivity is required.
  • Use <Suspense> boundaries to enable streaming for slow data-fetching sections.
  • Prefer ISR over full force-dynamic when data doesn't need to be real-time.
  • Use revalidateTag for precise, on-demand cache invalidation after mutations.

Common Mistakes ⚠️

  • Marking entire pages as "use client" when only a small part needs interactivity.
  • Accidentally opting a route into dynamic rendering by calling cookies() unnecessarily.
  • Forgetting <Suspense> boundaries, causing the whole page to block on slow data.
  • Assuming ISR updates instantly — it respects the configured revalidate window.

Frequently Asked Questions 💬

Question

Is Server-Side Rendering the same as Server Components?

Answer

No — SSR describes when HTML is generated (per-request), while Server Components describe where a component's code runs (server-only, no client JS).

Question

Can a single page mix static and dynamic content?

Answer

Yes — this is exactly what Partial Prerendering and nested <Suspense> boundaries enable.

Question

Does ISR work with fully dynamic routes?

Answer

No — ISR applies to statically generated pages; fully dynamic routes re-render on every request instead.

Summary 📌

Summary

Rendering in Next.js isn't an all-or-nothing choice — combining strategies at the route and component level is the key to fast, fresh, and scalable applications.