# Static Site Generation

Course: [React](https://www.devtutorials.ai/learn/react) · Section: Render Modes
Source: https://www.devtutorials.ai/learn/react/rendering/static-site-generation

> How SSG works: build-time rendering, static HTML generation, the build process, build-time data fetching, dynamic routes, deployment to a CDN, SSG pros and cons, and a Documentation Website capstone to download.

## Introduction to Static Site Generation

**Static site generation (SSG)** means React renders your pages **once, at build time**, into plain HTML files. You upload these files to a host. Every visitor then gets a ready-made file. Nothing is rendered when they visit.

It is a third render mode, next to CSR and SSR:

<figure className="react-fig">
<svg viewBox="0 0 520 150" role="img" aria-labelledby="fig-ssg-modes" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssg-modes">When is the HTML made? CSR in the browser, SSR on each request, SSG once at build time</title>
  <text x="10" y="32" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>CSR</text>
  <rect x="70" y="15" width="445" height="28" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="292.5" y="34.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>made in each browser, after the JavaScript loads</text>
  <text x="10" y="77" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>SSR</text>
  <rect x="70" y="60" width="445" height="28" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="292.5" y="79.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>made on the server, for every request</text>
  <text x="10" y="122" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>SSG</text>
  <rect x="70" y="105" width="445" height="28" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="292.5" y="124.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>made once, at build time, before anyone visits</text>
  <rect x="70" y="105" width="445" height="28" rx="8" style={{ fill: "none", stroke: "var(--ok)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3s" begin="0s" repeatCount="indefinite" />
  </rect>
</svg>
<figcaption>All three use the same React components. Only the time and place change.</figcaption>
</figure>

### Real-World Example

A bookshop's help center has 30 pages, such as "Shipping" and "Returns". The text changes only a few times a year.

At build time, the build script renders every page to an HTML file. The host sends these ready files to every visitor, so the pages load very fast and no server code runs.

## Benefits of SSG

- **Very fast.** The files are ready. A host or CDN just sends them.
- **Cheap and simple hosting.** Any static host works. No Node.js server is needed.
- **Great SEO.** Every page is full HTML, like SSR.
- **Safe and reliable.** No server code runs per request, so there is less that can break or be attacked.

SSG is a great fit for content that does not change every minute: documentation, blogs, marketing pages and portfolios.

## SSG Architecture

### Build-Time Rendering

**Build time** is when you run `npm run build`, on your computer or a build server. **Request time** is when a user visits the site.

In SSG, React renders at **build time**. In SSR, it renders at request time. That is the main difference.

<figure className="react-fig">
<svg viewBox="0 0 520 115" role="img" aria-labelledby="fig-ssg-flow" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssg-flow">Static site generation: pages are rendered once at build time, then the same files are sent to every visitor</title>
  <rect x="5" y="20" width="110" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="60.0" y="42.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>npm run build</text>
  <text x="60.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>once</text>
  <path d="M115 45 L145 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M145 45 L136.7 48.5 L136.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="150" y="20" width="110" height="50" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="205.0" y="42.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>React</text>
  <text x="205.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>renders pages</text>
  <path d="M260 45 L290 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M290 45 L281.7 48.5 L281.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="295" y="10" width="100" height="26" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="345.0" y="28.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>index.html</text>
  <rect x="295" y="40" width="100" height="26" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="345.0" y="58.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>docs/*.html</text>
  <path d="M395 45 L420 45" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M420 45 L411.7 48.5 L411.7 41.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="425" y="20" width="90" height="50" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="470.0" y="42.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>CDN</text>
  <text x="470.0" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>stores files</text>
  <text x="260" y="100" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 12, fontWeight: 700, fontFamily: "var(--sans)" }}>Later: every visitor gets a ready file. Nothing is rendered per request.</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="3s" repeatCount="indefinite" path="M115 45 L145 45 M260 45 L290 45 M395 45 L420 45" />
  </circle>
</svg>
<figcaption>The work happens before anyone visits.</figcaption>
</figure>

### Static HTML Generation

During the build, a script calls React for each page, and saves the result as a file. For pure static pages, React has `renderToStaticMarkup`. It is like `renderToString`, but it leaves out extra markers that only hydration needs, so the HTML is a little smaller.

```tsx
import { renderToStaticMarkup } from "react-dom/server";

const html = renderToStaticMarkup(<DocPage doc={doc} />);
fs.writeFileSync("dist/docs/props/index.html", html);
```

If the page also needs to be interactive later, use `renderToString` and hydrate it in the browser, like in SSR.

### Pre-Rendered Pages

The output is a folder of **pre-rendered pages**: `.html` files that already contain the content. Each URL maps to a file:

- `/` → `index.html`
- `/docs/props/` → `docs/props/index.html`

Run the lab. The "build" renders three pages. Click a file to see its HTML.

      <h1>{title}</h1>
      <p>{text}</p>
    </main>
  );
}

const pages = [
  { file: "index.html", title: "Home", text: "Welcome to Book Corner." },
  { file: "about/index.html", title: "About", text: "A small bookshop since 2010." },
  { file: "contact/index.html", title: "Contact", text: "Write to hello@bookcorner.dev." },
];

  // The "build": render every page once
  const dist = useMemo(() => {
    const files: Record<string, string> = {};
    for (const p of pages) {
      files[p.file] = renderToStaticMarkup(<Page title={p.title} text={p.text} />);
      console.log("built dist/" + p.file + " (" + files[p.file].length + " bytes)");
    }
    return files;
  }, []);
  const [open, setOpen] = useState("index.html");

  return (
    <div style={{ display: "flex", gap: 12, fontFamily: "sans-serif" }}>
      <ul style={{ listStyle: "none", padding: 0, fontFamily: "monospace" }}>
        <li><b>dist/</b></li>
        {Object.keys(dist).map((f) => (
          <li key={f}>
            <button onClick={() => setOpen(f)} style={{ fontWeight: f === open ? "bold" : "normal" }}>📄 {f}</button>
          </li>
        ))}
      </ul>
      <pre style={{ whiteSpace: "pre-wrap", background: "#f4f4f4", padding: 8, flex: 1 }}>{dist[open]}</pre>
    </div>
  );
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

## SSG Rendering Fundamentals

### Build Process

A typical SSG build has four steps:

1. **Read** the content (files, a CMS, an API).
2. **List** every page that must exist. This list of paths is often made by a function called `getStaticPaths`.
3. **Render** each page with React.
4. **Write** each result as an `.html` file in the `dist` folder, together with CSS, JavaScript and images.

<figure className="react-fig">
<svg viewBox="0 0 520 120" role="img" aria-labelledby="fig-ssg-build" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssg-build">The build process: read content, list the pages, render each one, write the files</title>
  <rect x="5" y="30" width="120" height="52" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="65.0" y="53.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>1. Read</text>
  <text x="65.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>content + data</text>
  <rect x="135" y="30" width="120" height="52" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="195.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>2. List paths</text>
  <text x="195.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>getStaticPaths</text>
  <rect x="265" y="30" width="120" height="52" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="325.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>3. Render</text>
  <text x="325.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>each page</text>
  <rect x="395" y="30" width="120" height="52" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="455.0" y="53.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>4. Write</text>
  <text x="455.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>dist/*.html</text>
  <rect x="5" y="30" width="120" height="52" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3.2s" begin="0.0s" repeatCount="indefinite" />
  </rect>
  <rect x="135" y="30" width="120" height="52" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3.2s" begin="0.8s" repeatCount="indefinite" />
  </rect>
  <rect x="265" y="30" width="120" height="52" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3.2s" begin="1.6s" repeatCount="indefinite" />
  </rect>
  <rect x="395" y="30" width="120" height="52" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3.2s" begin="2.4000000000000004s" repeatCount="indefinite" />
  </rect>
  <text x="260" y="108" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>runs on your computer or a CI server, not when users visit</text>
</svg>
<figcaption>After the build, the site is just a folder of files.</figcaption>
</figure>

### Page Generation

Each path becomes **one file**. The same layout component is used for every page, so all pages look alike. Pages are made one after another, so a site with 1,000 pages makes 1,000 files. A big site can take a while to build.

### Serving Static Content

After the build, there is no React work left for visitors. The host just finds the file for the URL and sends it.

The simulator below compares SSR and SSG. Press **Send 5 requests** and compare how many times each one renders.

      {Array.from({ length: n }, (_, i) => (
        <span key={i} style={{ width: 14, height: 18, borderRadius: 3, background: color }} />
      ))}
    </div>
  );

  return (
    <div style={{ fontFamily: "sans-serif", maxWidth: 460 }}>
      <button onClick={runBuild} disabled={built}>1. npm run build</button>{" "}
      <button onClick={() => setPending(pending + 5)} disabled={!built}>2. Send 5 requests</button>
      <p>Requests served: <b>{requests}</b></p>
      <p>SSR renders (one per request): <b>{ssrRenders}</b></p>
      {bar(ssrRenders, "#e07b00")}
      <p>SSG renders (only at build time): <b>{ssgRenders}</b></p>
      {bar(ssgRenders, "#1e7b34")}
      <p style={{ color: "#666" }}>
        {built ? "SSG visitors get ready files. More requests do not mean more rendering." : "Run the build first."}
      </p>
    </div>
  );
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

Easy Way to Remember: CSR renders in the browser, SSR renders per request, SSG renders once per build.

## Content Generation

### Build-Time Data Fetching

Pages often need data: posts from an API, docs from files. In SSG, this data is fetched **during the build**, once. The result is baked into the HTML. Visitors never wait for it.

### Generating Dynamic Routes

A **dynamic route** like `/blog/[id]` is one template for many pages. At build time, you must say **which** ids exist. You fetch the list, and make one path per item. Then each path is rendered with its own data.

The lab does both. The "build" fetches the posts, makes one path per post, and renders a file for each.

<figure className="react-fig">
<svg viewBox="0 0 520 150" role="img" aria-labelledby="fig-ssg-routes" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssg-routes">Generating dynamic routes: one template plus a list of slugs makes many HTML files</title>
  <rect x="10" y="50" width="150" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="85.0" y="72.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/docs/[slug]</text>
  <text x="85.0" y="89.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>one template</text>
  <rect x="10" y="110" width="150" height="30" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="85.0" y="130.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>slugs from data</text>
  <path d="M160 75 L250 30" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M250 30 L244.2 36.8 L241.0 30.6 Z" style={{ fill: "var(--faint)" }} />
  <path d="M160 75 L250 75" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M250 75 L241.7 78.5 L241.7 71.5 Z" style={{ fill: "var(--faint)" }} />
  <path d="M160 75 L250 120" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M250 120 L241.0 119.4 L244.2 113.2 Z" style={{ fill: "var(--faint)" }} />
  <rect x="255" y="12" width="250" height="32" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="380.0" y="33.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>docs/introduction/index.html</text>
  <rect x="255" y="58" width="250" height="32" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="380.0" y="79.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>docs/installation/index.html</text>
  <rect x="255" y="104" width="250" height="32" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="380.0" y="125.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>docs/props/index.html</text>
  <rect x="255" y="12" width="250" height="32" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3s" begin="0s" repeatCount="indefinite" />
  </rect>
  <rect x="255" y="58" width="250" height="32" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3s" begin="0.5s" repeatCount="indefinite" />
  </rect>
  <rect x="255" y="104" width="250" height="32" rx="8" style={{ fill: "none", stroke: "var(--accent-secondary)", strokeWidth: 3 }} opacity="0">
    <animate attributeName="opacity" values="0;1;0;0" keyTimes="0;0.15;0.4;1" dur="3s" begin="1s" repeatCount="indefinite" />
  </rect>
</svg>
<figcaption>Add a doc to the data, rebuild, and a new page appears.</figcaption>
</figure>

      <h1>{post.title}</h1>
      <p>{"By " + post.author}</p>
      <p>{post.body}</p>
    </article>
  );
}

// getStaticPaths: which pages must exist?
async function getStaticPaths(): Promise<number[]> {
  const response = await fetch("https://api.bookcorner.dev/posts?delay=0");
  const posts: Post[] = await response.json();
  return posts.map((p) => p.id);
}

// The build: fetch data once, write one file per post
async function build(): Promise<Record<string, string>> {
  const files: Record<string, string> = {};
  for (const id of await getStaticPaths()) {
    const response = await fetch("https://api.bookcorner.dev/posts/" + id + "?delay=0");
    const post: Post = await response.json();
    files["blog/" + id + "/index.html"] = renderToStaticMarkup(<PostPage post={post} />);
  }
  return files;
}

  const [files, setFiles] = useState<Record<string, string>>({});

  useEffect(() => {
    build().then((result) => {
      console.log("Build finished: " + Object.keys(result).length + " pages");
      setFiles(result);
    });
  }, []);

  return (
    <div style={{ fontFamily: "sans-serif" }}>
      <h3>dist/</h3>
      {Object.entries(files).map(([name, html]) => (
        <details key={name}>
          <summary style={{ fontFamily: "monospace" }}>{name}</summary>
          <pre style={{ whiteSpace: "pre-wrap", background: "#f4f4f4", padding: 6 }}>{html}</pre>
        </details>
      ))}
    </div>
  );
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

### Managing Static Content

Content for SSG sites usually lives in one of these places:

- **Files in the project**, like Markdown (`.md`) files. Writers edit text, not code. This is common for docs and blogs.
- **A headless CMS**, an online editor (such as Contentful or Strapi) that the build reads through an API.
- **Data files** like JSON, for lists such as products or team members.

Keep content separate from components. Then the layout can change without touching the text, and the text can change without touching code.

Tools like **Astro**, **Next.js** (static export), **Gatsby** and **VitePress** do SSG for you. They give you `getStaticPaths`-style functions, Markdown support and a build command. The ideas in this lesson are the same in all of them.

## Deployment and Delivery

### Static Assets

**Static assets** are files that do not change per visitor: the HTML pages, CSS, JavaScript bundles, images and fonts. After an SSG build, **the whole site** is static assets. Build tools add a short code (a **hash**) to file names like `index-4zsi.js`. When the file changes, its name changes, so browsers never use an old copy.

### Content Delivery Workflow

You upload the `dist` folder to a static host such as Netlify, Vercel, Cloudflare Pages or GitHub Pages. Most of them use a **CDN** (Content Delivery Network): copies of your files are kept on servers around the world. Each visitor gets the files from the nearest one.

<figure className="react-fig">
<svg viewBox="0 0 520 160" role="img" aria-labelledby="fig-ssg-cdn" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssg-cdn">Content delivery: a CDN keeps copies of the files near users all over the world</title>
  <rect x="200" y="10" width="120" height="44" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="260.0" y="29.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Origin</text>
  <text x="260.0" y="46.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>dist/ folder</text>
  <rect x="10" y="100" width="130" height="44" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="75.0" y="119.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>CDN Mumbai</text>
  <text x="75.0" y="136.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>copy</text>
  <rect x="195" y="100" width="130" height="44" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="260.0" y="119.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>CDN London</text>
  <text x="260.0" y="136.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>copy</text>
  <rect x="380" y="100" width="130" height="44" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="445.0" y="119.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>CDN New York</text>
  <text x="445.0" y="136.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>copy</text>
  <path d="M230 54 L90 96" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M90 96 L96.9 90.3 L98.9 97.0 Z" style={{ fill: "var(--faint)" }} />
  <path d="M260 54 L260 96" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M260 96 L256.5 87.7 L263.5 87.7 Z" style={{ fill: "var(--faint)" }} />
  <path d="M290 54 L430 96" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M430 96 L421.1 97.0 L423.1 90.3 Z" style={{ fill: "var(--faint)" }} />
  <circle r="6" style={{ fill: "var(--accent)" }}>
    <animateMotion dur="2.4s" repeatCount="indefinite" path="M230 54 L90 96" />
  </circle>
  <circle r="6" style={{ fill: "var(--accent)" }}>
    <animateMotion dur="2.4s" repeatCount="indefinite" path="M260 54 L260 96" />
  </circle>
  <circle r="6" style={{ fill: "var(--accent)" }}>
    <animateMotion dur="2.4s" repeatCount="indefinite" path="M290 54 L430 96" />
  </circle>
</svg>
<figcaption>Each user gets the files from the nearest copy, so pages load fast.</figcaption>
</figure>

### Updating Generated Pages

Static pages do not change by themselves. To update one, you change the content, **rebuild** and **deploy** again. Most hosts do this automatically when you push to Git.

Try it below. Edit the price, and see that the live site still shows the old one until you rebuild and deploy.

<figure className="react-fig">
<svg viewBox="0 0 520 110" role="img" aria-labelledby="fig-ssg-update" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-ssg-update">Updating generated pages: change the content, rebuild, deploy</title>
  <rect x="10" y="30" width="140" height="50" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="80.0" y="52.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Edit content</text>
  <text x="80.0" y="69.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>fix a typo</text>
  <path d="M150 55 L185 55" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M185 55 L176.7 58.5 L176.7 51.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="190" y="30" width="140" height="50" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="260.0" y="52.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Rebuild</text>
  <text x="260.0" y="69.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>npm run build</text>
  <path d="M330 55 L365 55" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M365 55 L356.7 58.5 L356.7 51.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="370" y="30" width="140" height="50" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="440.0" y="52.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Deploy</text>
  <text x="440.0" y="69.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>upload dist/</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="2.4s" repeatCount="indefinite" path="M150 55 L185 55 M330 55 L365 55" />
  </circle>
  <text x="260" y="100" textAnchor="middle" style={{ fill: "var(--bad)", fontSize: 12, fontWeight: 700, fontFamily: "var(--sans)" }}>until you deploy, visitors still see the old files</text>
</svg>
<figcaption>Static pages change only when you build again.</figcaption>
</figure>

      <label>
        1. Content (price):{" "}
        <input type="number" value={content} onChange={(e) => setContent(Number(e.target.value))} />
      </label>
      <button onClick={() => { setBuilt(renderToStaticMarkup(<ProductPage price={content} />)); console.log("Rebuilt the HTML"); }}>
        2. npm run build
      </button>
      <button onClick={() => { setLive(built); console.log("Deployed: visitors now get the new file"); }}>
        3. Deploy
      </button>
      <div style={{ border: "1px solid #ccc", borderRadius: 8, padding: 8 }}>
        <small>dist/ (on your computer)</small>
        <code style={{ display: "block" }}>{built}</code>
      </div>
      <div style={{ border: "2px solid #1e7b34", borderRadius: 8, padding: 8 }}>
        <small>🌐 Live site (what visitors see)</small>
        <div dangerouslySetInnerHTML={{ __html: live }} />
      </div>
    </div>
  );
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

## Advantages and Limitations of SSG

<div className="tscroll">

| Advantages | Limitations |
|---|---|
| Fastest delivery: files are ready | Content is only as fresh as the last build |
| Cheap hosting on any static host or CDN | Big sites can take a long time to build |
| Full HTML for SEO and link previews | Not for per-user pages (a cart, a dashboard) |
| No server code per request: safe and reliable | Every change needs a rebuild and deploy |

</div>

<div className="tscroll">

| | CSR | SSR | SSG |
|---|---|---|---|
| When HTML is made | In the browser | On each request | Once, at build time |
| First view | Slow | Fast | Fastest |
| Fresh data | Yes | Yes | Only after a rebuild |
| Hosting | Static files | Node.js server | Static files |
| Best for | Apps behind login | Dynamic public pages | Docs, blogs, marketing |

</div>

Many real sites mix modes: SSG for docs and marketing pages, SSR for search results, and CSR for the logged-in dashboard. Interactive parts of an SSG page can still be hydrated, like in SSR.

## Capstone Project: Documentation Website

You will build a **Documentation Website** for "Book Corner UI", a pretend component library. It has docs grouped into categories, a shared layout with a sidebar, and one static page per doc, made at build time.

### Create multiple documentation pages

All content lives in `data.ts`: each doc has a `slug` (its URL name), a `title`, a `category` and its `body`. `DocPage` shows one doc. To add a page, you add an item to the data. No new component is needed.

### Create reusable layout components

`DocsLayout` wraps every page with the same header and sidebar. It takes `current` (the active slug) and `children` (the page content). Each page looks consistent, and the layout is written once.

### Organize content using categories

`groupByCategory(docs)` turns the flat list into groups: Getting Started, Core Concepts and Guides. The sidebar shows one heading per category with its docs below. Click the sidebar links in the lab to switch docs.

    </div>
  );
}
`,
    "DocsLayout.tsx": `import type { ReactNode } from "react";

interface DocsLayoutProps {
  current: string;
  children: ReactNode;
}

// The same layout wraps every page: header, sidebar and content
  const groups = groupByCategory(docs);
  return (
    <div className="docs">
      <header className="top"><a href="/">Book Corner UI Docs</a></header>
      <div className="body">
        <nav className="side">
          {groups.map((g) => (
            <div key={g.category} className="category">
              <h4>{g.category}</h4>
              <ul>
                {g.docs.map((d) => (
                  <li key={d.slug}>
                    <a href={"/docs/" + d.slug + "/"} className={d.slug === current ? "active" : ""}>
                      {d.title}
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </nav>
        <main className="content">{children}</main>
      </div>
    </div>
  );
}
`,
    "DocPage.tsx": `import type { Doc } from "./types";

  return (
    <article>
      <p className="crumb">{doc.category}</p>
      <h1>{doc.title}</h1>
      {doc.body.map((text, i) => (
        <p key={i}>{text}</p>
      ))}
    </article>
  );
}
`,
    "categories.ts": `import type { Doc } from "./types";

  category: string;
  docs: Doc[];
}

// Keep the categories in the order they first appear
  const groups: DocGroup[] = [];
  for (const doc of list) {
    let group = groups.find((g) => g.category === doc.category);
    if (!group) {
      group = { category: doc.category, docs: [] };
      groups.push(group);
    }
    group.docs.push(doc);
  }
  return groups;
}
`,
    "data.ts": `import type { Doc } from "./types";

// The content of the site. At build time, one page is made for each doc.
  { slug: "introduction", title: "Introduction", category: "Getting Started",
    body: ["Book Corner UI is a small set of React components for book shops.", "This guide shows how to install it and build your first page."] },
  { slug: "installation", title: "Installation", category: "Getting Started",
    body: ["Install the package with npm install bookcorner-ui.", "Then import the components you need in your app."] },
  { slug: "components", title: "Components", category: "Core Concepts",
    body: ["Every part of the screen is a component.", "Components are functions that return TSX."] },
  { slug: "props", title: "Props", category: "Core Concepts",
    body: ["Props pass data from a parent to a child.", "A child reads props but never changes them."] },
  { slug: "deployment", title: "Deployment", category: "Guides",
    body: ["Run npm run build to make the static files.", "Upload the dist folder to any static host or CDN."] },
];
`,
    "types.ts": `export interface Doc {
  slug: string;
  title: string;
  category: string;
  body: string[];
}
`,
    "styles.css": `.docs { font-family: sans-serif; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; max-width: 600px; }
.top { background: #2b59c3; padding: 8px 12px; }
.top a { color: white; font-weight: bold; text-decoration: none; }
.body { display: flex; }
.side { width: 170px; background: #f4f6fb; padding: 8px; font-size: 13px; }
.side h4 { margin: 8px 0 4px; color: #555; font-size: 12px; text-transform: uppercase; }
.side ul { list-style: none; margin: 0; padding: 0; }
.side a { display: block; padding: 3px 6px; border-radius: 4px; color: #333; text-decoration: none; }
.side a.active { background: #2b59c3; color: white; }
.content { flex: 1; padding: 10px 14px; }
.content h1 { margin: 0 0 6px; font-size: 22px; }
.crumb { color: #888; font-size: 12px; margin: 0; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

### Configure dynamic documentation routes

The route `/docs/[slug]/` is **one** pattern for all docs. `getStaticPaths()` in `build.tsx` lists every real path: `/` plus one `/docs/<slug>/` per doc.

### Generate pages at build time

`build()` loops over the paths, renders each page with `renderToStaticMarkup` inside `DocsLayout`, and stores it as a file like `docs/props/index.html`.

### Implement navigation and routing

The sidebar uses plain `<a href="/docs/props/">` links. On a static site, each click asks the host for the next HTML file. The host finds `docs/props/index.html` and sends it. No React runs for the visit.

The finished lab plays both parts: it runs the build once, then acts as the **static host**. Click the sidebar links. The console shows that each page is a file lookup, not a render.

      <p style={{ fontFamily: "monospace", fontSize: 13 }}>
        🌐 docs.bookcorner.dev{path} · file: <b>{file}</b>
      </p>
      <div onClick={handleClick} dangerouslySetInnerHTML={{ __html: html ?? "<p>404</p>" }} />
    </div>
  );
}
`,
    "build.tsx": `import { renderToStaticMarkup } from "react-dom/server";

// 1. Which pages exist? One path per doc (dynamic routes).
  return ["/", ...docs.map((d) => "/docs/" + d.slug + "/")];
}

// 2. Render one page to a full HTML file
function renderPage(path: string): string {
  const slug = path.split("/")[2];
  const doc = docs.find((d) => d.slug === slug);
  const page = doc ? (
    <DocsLayout current={doc.slug}><DocPage doc={doc} /></DocsLayout>
  ) : (
    <DocsLayout current=""><h1>Welcome</h1><p>Pick a topic on the left.</p></DocsLayout>
  );
  return "<!DOCTYPE html><html><body>" + renderToStaticMarkup(page) + "</body></html>";
}

// 3. The build: make every file once, before any user visits
  const files: Record<string, string> = {};
  for (const path of getStaticPaths()) {
    files[path + "index.html"] = renderPage(path);
  }
  return files;
}
`,
    "DocsLayout.tsx": `import type { ReactNode } from "react";

interface DocsLayoutProps {
  current: string;
  children: ReactNode;
}

// The same layout wraps every page: header, sidebar and content
  const groups = groupByCategory(docs);
  return (
    <div className="docs">
      <header className="top"><a href="/">Book Corner UI Docs</a></header>
      <div className="body">
        <nav className="side">
          {groups.map((g) => (
            <div key={g.category} className="category">
              <h4>{g.category}</h4>
              <ul>
                {g.docs.map((d) => (
                  <li key={d.slug}>
                    <a href={"/docs/" + d.slug + "/"} className={d.slug === current ? "active" : ""}>
                      {d.title}
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </nav>
        <main className="content">{children}</main>
      </div>
    </div>
  );
}
`,
    "DocPage.tsx": `import type { Doc } from "./types";

  return (
    <article>
      <p className="crumb">{doc.category}</p>
      <h1>{doc.title}</h1>
      {doc.body.map((text, i) => (
        <p key={i}>{text}</p>
      ))}
    </article>
  );
}
`,
    "categories.ts": `import type { Doc } from "./types";

  category: string;
  docs: Doc[];
}

// Keep the categories in the order they first appear
  const groups: DocGroup[] = [];
  for (const doc of list) {
    let group = groups.find((g) => g.category === doc.category);
    if (!group) {
      group = { category: doc.category, docs: [] };
      groups.push(group);
    }
    group.docs.push(doc);
  }
  return groups;
}
`,
    "data.ts": `import type { Doc } from "./types";

// The content of the site. At build time, one page is made for each doc.
  { slug: "introduction", title: "Introduction", category: "Getting Started",
    body: ["Book Corner UI is a small set of React components for book shops.", "This guide shows how to install it and build your first page."] },
  { slug: "installation", title: "Installation", category: "Getting Started",
    body: ["Install the package with npm install bookcorner-ui.", "Then import the components you need in your app."] },
  { slug: "components", title: "Components", category: "Core Concepts",
    body: ["Every part of the screen is a component.", "Components are functions that return TSX."] },
  { slug: "props", title: "Props", category: "Core Concepts",
    body: ["Props pass data from a parent to a child.", "A child reads props but never changes them."] },
  { slug: "deployment", title: "Deployment", category: "Guides",
    body: ["Run npm run build to make the static files.", "Upload the dist folder to any static host or CDN."] },
];
`,
    "types.ts": `export interface Doc {
  slug: string;
  title: string;
  category: string;
  body: string[];
}
`,
    "styles.css": `.docs { font-family: sans-serif; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; max-width: 600px; }
.top { background: #2b59c3; padding: 8px 12px; }
.top a { color: white; font-weight: bold; text-decoration: none; }
.body { display: flex; }
.side { width: 170px; background: #f4f6fb; padding: 8px; font-size: 13px; }
.side h4 { margin: 8px 0 4px; color: #555; font-size: 12px; text-transform: uppercase; }
.side ul { list-style: none; margin: 0; padding: 0; }
.side a { display: block; padding: 3px 6px; border-radius: 4px; color: #333; text-decoration: none; }
.side a.active { background: #2b59c3; color: white; }
.content { flex: 1; padding: 10px 14px; }
.content h1 { margin: 0 0 6px; font-size: 22px; }
.crumb { color: #888; font-size: 12px; margin: 0; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);
`,
  }}
/>

## Download Project Files

### Download SSG Project Source Code

The complete Documentation Website is ready as a real SSG project with **Vite**, **React**, **TypeScript** and **React Router**. The docs are Markdown files. `npm run build` pre-renders one HTML file per doc into `dist/static`. In the browser, the pages are hydrated, so moving between docs is instant.

Download SSG Project: [Download Documentation Website Project](/downloads/react/ssg-docs-website.zip)

```
docs-website/
  package.json            scripts and dependencies
  prerender.js            build step: render every route to dist/static
  vite.config.ts          Vite settings
  index.html              template with <!--app-html-->
  content/docs/           the Markdown docs, one folder per category
  src/
    entry-client.tsx      hydrate (or render in dev)
    entry-server.tsx      render(url) + getStaticPaths()
    App.tsx               routes
    content/docs.ts       reads and parses the Markdown at build time
    layouts/DocsLayout.tsx
    components/           Sidebar, Header, DocNav (previous / next)
    pages/                HomePage, DocPage, NotFoundPage
```

### Project Setup Instructions

1. Install **Node.js** 18 or newer from nodejs.org, if you do not have it.
2. Download the ZIP with the link above, and unzip it. You get a folder called `docs-website`.
3. Open a terminal in that folder (in VS Code: File, Open Folder, then Terminal, New Terminal).
4. Install the packages and start the dev server:

```bash
npm install
npm run dev
```

Open `http://localhost:5173`. To make the static site, run `npm run build`, then `npm run preview` to serve the generated files from `dist/static`. Add a new `.md` file in `content/docs`, build again, and a new page appears.

After `npm run build`, open the `dist/static` folder. You will find a real `index.html` for every doc, already filled with its text. That folder is the whole website: you can upload it to any static host.

## Common mistakes

<div className="tscroll">

| Mistake | What you see | Fix |
|---|---|---|
| Expecting content changes to show without a rebuild | The live site still shows old text | Rebuild and deploy, or let the host build on every push |
| Using SSG for per-user data, like a cart | Every visitor sees the same page | Load user data on the client, or use SSR |
| Forgetting a slug in `getStaticPaths` | That page is a 404 | Make the paths from the same data as the pages |
| Using `window` while rendering at build time | Build error: window is not defined | Use browser-only code in `useEffect` |
| Mixing content into components | Every text change needs a code change | Keep content in Markdown or data files |
| Using `renderToStaticMarkup` for pages you want to hydrate | Hydration warnings, clicks do not work | Use `renderToString` for pages that will be hydrated |

</div>

## Check Yourself

## Exercises

## What to Remember

- SSG renders pages **once at build time** into HTML files. Visitors get ready files from a static host or CDN.
- The build reads content, lists paths with `getStaticPaths`, renders each page and writes the files. Dynamic routes become one file per item.
- SSG is fast, cheap and great for SEO, but content is only as fresh as the last build. Use it for docs, blogs and marketing pages.

Next: Incremental Static Generation
