Skip to content
CSR · SSR · SSG기술 정리

A close look at Next.js rendering: CSR, SSR, SSG, ISR, and Hydration

7 min readTech notesMoved from my Tistory blogRead the Korean original

These are notes I put together while studying rendering in ASAP, a study group that meets once a week.

I wrote them to share with the study group, but they seemed organized well enough to look up again later, so I moved them to my personal blog too.

I looked into the four ways Next.js builds a page and into Hydration. The code is based on the Next.js 16 App Router.

What is rendering

Rendering is the process of turning the components and data of a web application into HTML that the browser can show on screen.

Take one component as an example.

jsx
export default function Page() {
  return <h1>Hello World</h1>;
}

The result of rendering this component is HTML.

html
<h1>Hello World</h1>

Up to here, every method is the same. Where they split is who does this conversion and when.

Method When the HTML is made
CSR In the browser, after JavaScript runs
SSR On the server, every time a request comes in
SSG Once, at build time
ISR At build time, and again after a set interval

Placed on a timeline, they line up like this.

Timeline of when each of the four methods generates HTML
Figure 1. Timeline of when each of the four methods generates HTML

The further left, the more is made ahead of time, so the response is faster. The further right, the closer to the time of the request, so the data is more current.

What is Hydration

Say the server built this HTML and sent it.

html
<button>장바구니에 담기</button>

The add-to-cart button is drawn on the screen. But nothing happens when you click it. The HTML only has the shape, with no behavior such as onClick or useState attached.

When the browser downloads the JavaScript and runs React, React finds the HTML that is already there and matches it up with its own components. This process is Hydration.

Before Hydration the button is only visible, and it responds to clicks only after React is connected
Figure 2. Before Hydration the button is only visible, and it responds to clicks only after React is connected

The component that gets connected looks like this.

jsx
'use client';

import { useState } from 'react';

export default function AddToCartButton() {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => setAdded(!added)}>
      {added ? '장바구니에 담김' : '장바구니에 담기'}
    </button>
  );
}

React doesn't create the DOM anew. It uses the <button> the server sent as is and only connects the event handlers and state. That's why the screen doesn't flicker.

Hydration is different from CSR

An app that starts with an empty <div id="root"> has no Hydration. A React app made with Vite is one of these. React creates the DOM from scratch, so there is no HTML to take over at all.

Order of steps in pure CSR compared with SSR and SSG
Figure 3. Order of steps in pure CSR compared with SSR and SSG

The path on the right has a stretch where the screen is visible first and only then starts responding to clicks. The path on the left has no such stretch, but the screen itself appears later.

That said, in the Next.js App Router this distinction doesn't apply as is. Even a component marked 'use client' gets its first markup from the server. The browser only fills in the data that goes inside it, so Hydration still happens. The case with no Hydration at all is a pure CSR app, where the server sends only an empty shell.

CSR (Client-Side Rendering)

JavaScript runs in the browser first, and then it fetches the data and fills in the screen.

How CSR works, step by step
Figure 4. How CSR works, step by step
tsx
'use client';

import { useEffect, useState } from 'react';

export default function CartPage() {
  const [items, setItems] = useState<CartItem[]>([]);

  // Runs in the browser, so the cart is empty in the first HTML
  useEffect(() => {
    fetch('/api/cart')
      .then((res) => res.json())
      .then(setItems);
  }, []);

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}
Screens in a shopping service where CSR fits
Figure 5. Screens in a shopping service where CSR fits

The first HTML has no data, and the browser fetches it itself. That makes it a good fit for screens with a lot of interaction. On the other hand, the data request starts only after the JS has been downloaded, so the initial load is the slowest.

SSR (Server-Side Rendering)

On every request, the server fetches the data, completes the HTML, and sends it.

How SSR works, step by step
Figure 6. How SSR works, step by step
tsx
export default async function OrderListPage() {
  // no-store fetches fresh data on every request. With force-cache instead, it keeps using the cached response
  const response = await fetch('https://example.com/api/orders', {
    cache: 'no-store',
  });
  const orders: Order[] = await response.json();

  return (
    <ul>
      {orders.map((order) => (
        <li key={order.id}>{order.title}</li>
      ))}
    </ul>
  );
}
Screens in a shopping service where SSR fits
Figure 7. Screens in a shopping service where SSR fits

The latest data at the time of the request goes into the HTML. It can show different content to each user, and the content is visible without JS, which helps with search visibility. But the server works on every request, so the first response is slower than with SSG.

SSG (Static Site Generation)

The HTML is completed at build time, and the same file is returned for every request.

How SSG works, step by step
Figure 8. How SSG works, step by step
tsx
export default async function EventListPage() {
  // force-cache keeps using the response fetched at build time. With no-store instead, it fetches fresh data on every request
  const response = await fetch('https://example.com/api/events', {
    cache: 'force-cache',
  });
  const events: Event[] = await response.json();

  return (
    <ul>
      {events.map((event) => (
        <li key={event.id}>{event.title}</li>
      ))}
    </ul>
  );
}

Note: The reason to spell out cache: 'force-cache' is the version. From Next 13 through 14, fetch responses were cached by default, but 15 flipped that default. In 16 you also have to turn caching on yourself, so if you leave it out, the response isn't kept in the cache.

Screens in a shopping service where SSG fits
Figure 9. Screens in a shopping service where SSG fits

The HTML is fixed at build time, so the server doesn't render on each request. It's easy to put on a CDN, so the response is the fastest. But changing the content requires a new build, and personalization isn't possible.

ISR (Incremental Static Regeneration)

It serves static pages built with SSG, and after a set time it builds them again and swaps them in.

How ISR works, step by step
Figure 10. How ISR works, step by step

revalidate: 60 doesn't mean it rebuilds on its own every 60 seconds. When a request comes in after 60 seconds have passed, the old HTML is served first and the server builds a new one in the background. The new HTML goes out starting with the next request.

Request timing when the revalidation interval is 60 seconds
Figure 11. Request timing when the revalidation interval is 60 seconds

If nobody visits, no regeneration happens either.

tsx
export default async function ProductListPage() {
  // The first request after 60 seconds triggers regeneration. false caches indefinitely, 0 doesn't cache
  const response = await fetch('https://example.com/api/products', {
    next: { revalidate: 60 },
  });
  const products: Product[] = await response.json();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}
Screens in a shopping service where ISR fits
Figure 12. Screens in a shopping service where ISR fits

The response is as fast as SSG, and pages are updated one by one without rebuilding everything. Data up to one interval old can show up. So it fits screens that don't need to be real time and only need to be current periodically.

You can also decide with route settings instead of fetch

The four examples above split the methods with fetch options. When there's no fetch, for example when reading a DB directly, you decide by exporting a route setting at the top of the file.

Method Route setting
SSR export const dynamic = 'force-dynamic'
SSG Nothing to write. Static is the default
ISR export const revalidate = 60

SSR is written like this.

jsx
// Re-render on the server for every request
export const dynamic = 'force-dynamic';

export default async function OrderListPage() {
  const orders = await db.orders.getAll();

  return (
    <ul>
      {orders.map((order) => (
        <li key={order.id}>{order.title}</li>
      ))}
    </ul>
  );
}

For ISR, you only export a number.

jsx
// Regenerate on the first request after 60 seconds
export const revalidate = 60;

export default async function ProductListPage() {
  const products = await db.products.getAll();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

For SSG there's nothing to write. If you leave the settings empty, it runs once at build time and stays static. If you want to keep it from becoming dynamic by mistake, you can set export const dynamic = 'error' so the build fails when the page can't be made static.

Caution:The revalidate value has to be a number that can be read at build time. revalidate = 600 works, but revalidate = 60 * 10 doesn't. dynamic and revalidate go away when you turn on Cache Components, covered below. In that case you use cacheLife instead.

Cache Components, what changed in Next.js 16

The two approaches above set the method with fetch options and route settings. 16 has a third approach. You turn on cacheComponents.

ts
// next.config.ts
import type { NextConfig } from 'next';

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

export default nextConfig;

Inside the function or component you want to cache, you write 'use cache' and set its lifetime with cacheLife.

jsx
import { cacheLife } from 'next/cache';

export default async function ProductListPage() {
  'use cache';
  cacheLife('hours'); // Reuse this result on the scale of hours

  const products = await getProducts();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Instead of fetch options or export const revalidate, you put a mark on the thing being cached itself.

So why did it change this way? In the previous model, the unit of the decision was the route. If you read cookies() once anywhere on a page, the whole page switches to request-time rendering. For example, the product list could be cached, but one recommendations area forces the whole page to be drawn again.

Cache Components moved that unit down to functions and components. The official docs describe it like this.

Data fetching is dynamic by default, and you choose what to cache at the page, component, or function level.

So one page can have a static shell, cached areas, and areas drawn on every request, all together. If you wrap only the parts that change per request in <Suspense>, the rest goes out first as a shell made ahead of time. This is Partial Prerendering, and it becomes the default behavior when you turn on cacheComponents.

The way caching is turned on was also flipped. Before, the framework decided on its own by looking at the fetch options and whether request-time APIs were used. That's why it was hard to trace back why a page had become dynamic. Now the default is dynamic, and you mark what to cache yourself.

Where it can be used also widened. The previous cache options were attached to fetch, so code that read a DB directly needed a separate API called unstable_cache. 'use cache' can go on any async function.

Caution:The two models can't be used together. Turning on cacheComponents removes dynamic, dynamicParams, revalidate, and fetchCache, and leaving them in causes an error. In 16 it's off by default. It's fine to understand the two approaches above first and come back to this later.

The four methods at a glance

Method When HTML is generated Where HTML is generated Data fetching Hydration
CSR After the browser runs the JS Browser Browser None (for pure CSR)
SSR On request Server On request Yes
SSG At build time Build server At build time Yes
ISR At build time + on regeneration Server On regeneration Yes

When you use a Client Component in the Next.js App Router, the first markup comes from the server, so Hydration happens.

Here is how the four methods can be split within one service.

Example of a shopping service screen divided into areas and assigned to the four methods
Figure 13. Example of a shopping service screen divided into areas and assigned to the four methods

The brand introduction is SSG, the product list is ISR, the personalized recommendations are SSR, and the filters and the add-to-cart button are CSR.

References

  • #Cache Components
  • #CSR
  • #Hydration
  • #ISR
  • #Next.js
  • #SSG
  • #SSR
  • #Rendering
  • #Server-Side Rendering
  • #Frontend

More in Tech notes