# SSR, SSG & meta-frameworks

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

Frontend Developer roadmap · Production · 19 min read · https://levelcode.org/roadmaps/frontend/rendering-strategies

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?**

| Strategy | HTML made | Good for | Cost |
|---|---|---|---|
| Client-side rendering (CSR) | In the browser, after JS loads | Apps behind a login, highly interactive tools | Blank page until JS runs; weak for SEO and slow devices |
| Static site generation (SSG) | Once, at build time | Docs, blogs, marketing pages | Content is only as fresh as the last build |
| Server-side rendering (SSR) | On the server, per request | Personalised or fast-changing pages | A server runs for every request; slower time to first byte |
| Incremental static regeneration (ISR) | At build, then regenerated in the background every N seconds | Catalogues, news, listings | Visitors 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](https://levelcode.org/roadmaps/frontend/web-performance).

## 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:

| Framework | Built on | Notes |
|---|---|---|
| Next.js | React, its own router | The most used; App Router with React Server Components |
| React Router (framework mode) | React Router, Vite | Formerly Remix; loaders and actions per route |
| TanStack Start | TanStack Router, Vite | Type-safe routing, server functions |
| Astro | Any UI library | Content 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.

> [!WARNING]
> A server component can read secrets, but anything it passes as a prop to a client component ends up in the HTML and the RSC payload. Pass the user's display name, not the whole user row with its password hash and email verification token.

## 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.

**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.

## Quiz

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

   A. Static site generation at build time
   B. Server-side rendering on every request
   C. Client-side rendering
   D. Rendering in a service worker

   **Answer: A.** The content is the same for everyone and only changes when you deploy, so building it once is fastest and cheapest. SSR would redo identical work on every request.

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

   A. Math.random is not allowed in server components
   B. The server and the browser rendered different output for the same component
   C. Hydration only supports strings
   D. The component is missing 'use client'

   **Answer: B.** Hydration expects the browser's first render to match the server's HTML. A random number differs between the two renders, so React detects the mismatch.

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

   A. Add 'use client' to the page so everything can use state
   B. Move all data fetching into useEffect
   C. Use a server action for each click
   D. Keep the page a server component and make only the picker a client component

   **Answer: D.** Pushing the client boundary to the leaf keeps the rest of the page server-only, so less JavaScript is shipped and data can still be fetched directly on the server.

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

   A. Nothing extra: only your form can call it
   B. Check that the caller is signed in and allowed to delete that comment, and validate the input
   C. Only validate the input
   D. Check the Referer header

   **Answer: B.** A server action is exposed as an HTTP endpoint that anyone can call with any arguments. It needs the same authentication, authorisation and validation as any API route.
