Step 18 of 21

SSR, SSG & meta-frameworks

Choose between client rendering, server rendering and static generation, and build pages with Next.js server components.

19 min 9 sections

So far your React app has rendered in the browser: the server sends an almost empty HTML file and a bundle, and nothing appears until the JavaScript runs. That's fine for a dashboard behind a login, but costly for a marketing page, a blog or a store, where first paint and search engines matter. This lesson explains the four rendering strategies, what hydration is, and how a meta-framework such as Next.js lets you pick a strategy per page with React Server Components.

Four ways to produce HTML

The question behind every strategy is: when, and where, does the HTML get made?

StrategyHTML madeGood forCost
Client-side rendering (CSR)In the browser, after JS loadsApps behind a login, highly interactive toolsBlank page until JS runs; weak for SEO and slow devices
Static site generation (SSG)Once, at build timeDocs, blogs, marketing pagesContent is only as fresh as the last build
Server-side rendering (SSR)On the server, per requestPersonalised or fast-changing pagesA server runs for every request; slower time to first byte
Incremental static regeneration (ISR)At build, then regenerated in the background every N secondsCatalogues, news, listingsVisitors may see data up to N seconds old

These aren't app-wide choices. A typical product has a static home page, server-rendered product pages, and a client-rendered account area, all in one codebase.

Compare what the browser receives first. With CSR:

html
<body>
  <div id="root"></div>
  <script type="module" src="/assets/index-3f9a.js"></script>
</body>

With SSR or SSG, the first response already contains the content:

html
<body>
  <div id="root">
    <h1>Mechanical keyboard</h1>
    <p>€129 · In stock</p>
    <button>Add to cart</button>
  </div>
  <script type="module" src="/assets/product-91c2.js"></script>
</body>

The user sees the product immediately, and a crawler that doesn't run JavaScript still reads it. This mainly improves Largest Contentful Paint, the metric from the web performance lesson.

Hydration: making server HTML interactive

The server-rendered button above looks right but does nothing: there are no event handlers yet. Hydration is React's process of attaching to existing HTML instead of replacing it:

tsx
import { hydrateRoot } from 'react-dom/client'
hydrateRoot(document.getElementById('root')!, <App />)

React renders the same component tree in the browser, walks the existing DOM, checks that it matches, and attaches event listeners. Two consequences follow.

  1. The page is visible before it is interactive. Between first paint and hydration, clicks do nothing. Shipping less JavaScript shrinks that gap.
  2. Server and client must render the same output. If the server renders new Date().toLocaleTimeString() at 10:00:00 and the browser hydrates at 10:00:02, the text differs and React reports a hydration mismatch. The same happens with Math.random(), window checks and locale-dependent formatting. Render such values after mount (in an effect), or pass them from the server as data.

Meta-frameworks

Wiring SSR yourself means a server that renders React to a stream, a router that runs on both sides, data loading before render, code splitting and a build that produces server and client bundles. A meta-framework packages this up:

FrameworkBuilt onNotes
Next.jsReact, its own routerThe most used; App Router with React Server Components
React Router (framework mode)React Router, ViteFormerly Remix; loaders and actions per route
TanStack StartTanStack Router, ViteType-safe routing, server functions
AstroAny UI libraryContent sites; ships zero JS unless a component needs it

The rest of this lesson uses Next.js, because it's what most job listings ask for, but the ideas transfer.

Next.js App Router basics

Create a project:

bash
npx create-next-app@latest shop --ts --app
cd shop && npm run dev

Routes are folders under app/. A page.tsx makes a folder routable; a layout.tsx wraps every page below it, just like a layout route in a client-side router:

text
app/
  layout.tsx            → wraps every page (html, body, nav)
  page.tsx              → /
  products/
    page.tsx            → /products
    [id]/
      page.tsx          → /products/:id
      loading.tsx       → shown while the page loads
      not-found.tsx     → shown when notFound() is called

The big shift: components in app/ are React Server Components (RSC) by default. They run only on the server (or at build time), can be async, and can read a database or a secret directly. Their code is never sent to the browser.

tsx
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation'
import { getProduct } from '@/lib/products' // queries the database
import { AddToCart } from './add-to-cart'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id)
  if (!product) notFound()

  return (
    <article>
      <h1>{product.name}</h1>
      <p>€{product.price}</p>
      <AddToCart productId={product.id} />
    </article>
  )
}

There's no useEffect, no loading state for the main data, and no API endpoint just to feed this page. In Next.js 15 and later, params is a promise, which is why it's awaited.

Server and client components

Server components can't use state, effects or browser APIs, because they never run in the browser. Anything interactive must be a client component, marked with 'use client' at the top of its file:

tsx
// app/products/[id]/add-to-cart.tsx
'use client'

import { useState } from 'react'

export function AddToCart({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false)
  return (
    <button onClick={() => setAdded(true)} disabled={added}>
      {added ? 'Added' : 'Add to cart'}
    </button>
  )
}

'use client' marks a boundary: this file and everything it imports are bundled for the browser. Client components are still rendered to HTML on the server and then hydrated; "client" means "also runs in the browser", not "only".

Rules that keep the boundary sane:

  • Push 'use client' down to the leaves. Make the button a client component, not the whole page. Everything above the boundary stays server-only and ships no JavaScript.
  • Props crossing the boundary must be serialisable: strings, numbers, plain objects, arrays, dates. You can't pass a function from a server component to a client component (server actions, below, are the exception).
  • A server component can render a client component, and pass server components to it as children. A client component can't import a server component directly.

Mutations with server actions

A server action is an async function marked 'use server' that the client can call like a normal function. Next.js turns the call into a POST request to the server:

tsx
// app/products/[id]/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { requireUser } from '@/lib/auth'
import { addReview } from '@/lib/reviews'

const reviewSchema = z.object({ productId: z.string(), text: z.string().min(10).max(2000) })

export async function submitReview(formData: FormData) {
  const user = await requireUser() // throws if not signed in
  const { productId, text } = reviewSchema.parse(Object.fromEntries(formData))
  await addReview({ productId, text, userId: user.id })
  revalidatePath(`/products/${productId}`) // re-render the page with the new review
}
tsx
<form action={submitReview}>
  <input type="hidden" name="productId" value={product.id} />
  <textarea name="text" required minLength={10} />
  <button>Post review</button>
</form>

Because it's a real form submission, it works before hydration too. Treat every server action as a public API endpoint: anyone can call it with any arguments, so authenticate, authorise and validate inside it, exactly as you would in a REST handler.

Choosing static or dynamic

Next.js decides per route. A page that reads nothing request-specific is prerendered at build time (SSG). A page that reads cookies, headers or search params, or opts out explicitly, is rendered per request (SSR):

tsx
// Static, regenerated at most once a minute (ISR):
export const revalidate = 60

// Or: always render per request.
export const dynamic = 'force-dynamic'

The build output marks each route as static or dynamic; check it after every npm run build. Next.js's caching APIs have changed between major versions, so confirm the defaults for the version you're on rather than assuming.

Streaming softens the cost of SSR. Wrap a slow part of the page in <Suspense> (or add a loading.tsx) and Next.js sends the rest of the HTML immediately, then streams the slow part into place when its data arrives:

tsx
<Suspense fallback={<p>Loading reviews…</p>}>
  <Reviews productId={id} />
</Suspense>

A decision guide

  • Content is the same for everyone and changes when you deploy → static.
  • Same for everyone but changes during the day → static with revalidation.
  • Depends on who's asking or must be up to the second → dynamic, with slow parts streamed.
  • Behind a login and highly interactive → a client-rendered section is fine; SEO doesn't matter there.

Practice

Create a Next.js app with a fake product catalogue (an array in lib/products.ts, with an async getProduct(id) that waits 300 ms).

  1. /products lists all products and is fully static. Confirm it in the build output.
  2. /products/[id] renders the product as a server component and calls notFound() for unknown IDs.
  3. Add a client-component "Add to cart" button that tracks a quantity with useState. Keep the rest of the page a server component.
  4. Add a reviews section that loads slowly (1.5 s) inside <Suspense>, and check that the product renders before the reviews.
  5. Deliberately render new Date().toLocaleTimeString() in the client component, observe the hydration warning in the console, then fix it.
Show solution
ts
// lib/products.ts
export type Product = { id: string; name: string; price: number }
const PRODUCTS: Product[] = [
  { id: 'kb', name: 'Mechanical keyboard', price: 129 },
  { id: 'ms', name: 'Wireless mouse', price: 49 },
]
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms))
export async function listProducts() { await wait(300); return PRODUCTS }
export async function getProduct(id: string) { await wait(300); return PRODUCTS.find((p) => p.id === id) }
export async function getReviews(id: string) { await wait(1500); return [`Great ${id}!`, 'Would buy again.'] }
tsx
// app/products/page.tsx  (static: reads nothing request-specific)
import Link from 'next/link'
import { listProducts } from '@/lib/products'

export default async function Products() {
  const products = await listProducts()
  return <ul>{products.map((p) => <li key={p.id}><Link href={`/products/${p.id}`}>{p.name}</Link></li>)}</ul>
}
tsx
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getProduct, getReviews, listProducts } from '@/lib/products'
import { AddToCart } from './add-to-cart'

export async function generateStaticParams() {
  return (await listProducts()).map((p) => ({ id: p.id }))
}

async function Reviews({ id }: { id: string }) {
  const reviews = await getReviews(id)
  return <ul>{reviews.map((r) => <li key={r}>{r}</li>)}</ul>
}

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id)
  if (!product) notFound()
  return (
    <article>
      <h1>{product.name}</h1>
      <p>€{product.price}</p>
      <AddToCart />
      <Suspense fallback={<p>Loading reviews…</p>}>
        <Reviews id={id} />
      </Suspense>
    </article>
  )
}
tsx
// app/products/[id]/add-to-cart.tsx
'use client'
import { useEffect, useState } from 'react'

export function AddToCart() {
  const [qty, setQty] = useState(0)
  // Fix for step 5: the time is only known in the browser, so set it after mount.
  const [time, setTime] = useState<string | null>(null)
  useEffect(() => setTime(new Date().toLocaleTimeString()), [])
  return (
    <div>
      <button onClick={() => setQty((q) => q + 1)}>Add to cart ({qty})</button>
      {time && <small>Viewed at {time}</small>}
    </div>
  )
}

generateStaticParams prerenders the known product pages at build time; the build output lists them as static (SSG). Rendering toLocaleTimeString() directly during render produces different text on the server and in the browser, which is the mismatch; moving it into an effect means the server and the first client render both output nothing for it.

Key takeaways

  • CSR, SSG, SSR and ISR differ in when and where HTML is made; pick per page, not per app.
  • Server-rendered HTML shows content sooner and is crawlable, but stays inert until hydration attaches React to it.
  • Server and client must render identical output; time, randomness and browser-only APIs cause hydration mismatches.
  • In the Next.js App Router, components are server components by default; add 'use client' only at interactive leaves and pass only safe, serialisable props across.
  • Server actions are public endpoints in disguise: authenticate, authorise and validate inside every one.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.A blog's posts change only when the author publishes, which triggers a deploy. Which strategy fits best?

  2. 2.React logs a hydration mismatch for a component that renders Math.random(). Why?

  3. 3.A product page in the App Router needs a quantity picker with useState. What's the best structure?

  4. 4.What must a server action that deletes a comment do, given it's called from your own form?

Finished reading?

Mark it complete to fill in your roadmap.