# Incremental Static Generation

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

> How ISG works: static generation with revalidation, stale-while-revalidate, background regeneration, on-demand revalidation, caching and consistency, ISG pros and cons, and a News Portal capstone to download.

## Introduction to Incremental Static Generation

**Incremental static generation (ISG)** is static generation that updates itself. Pages are made into HTML like in SSG, and visitors get the saved file fast. But each page also has a **revalidate time**, for example 60 seconds. After that time, the server makes a fresh copy **in the background**, one page at a time.

"Incremental" means pages are updated one by one, when needed, not by rebuilding the whole site. In Next.js this is called **Incremental Static Regeneration (ISR)**. It is the same idea.

<figure className="react-fig">
<svg viewBox="0 0 520 150" role="img" aria-labelledby="fig-isg-modes" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-isg-modes">ISG sits between SSG and SSR: pages are static, but they are regenerated after a set time</title>
  <text x="10" y="32" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>SSG</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)" }}>built once; changes need a full rebuild</text>
  <text x="10" y="77" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>ISG</text>
  <rect x="70" y="60" width="445" height="28" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="292.5" y="79.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>built once; each page refreshes itself after N seconds</text>
  <text x="10" y="122" textAnchor="start" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>SSR</text>
  <rect x="70" y="105" width="445" height="28" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="292.5" y="124.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>rendered again for every request</text>
  <rect x="70" y="60" 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>ISG keeps the speed of static pages and adds fresh content.</figcaption>
</figure>

### Real-World Example

A bookshop's "New arrivals" page lists books that change a few times a day. The server saves the rendered HTML page and sends the saved copy to every visitor.

After 60 seconds, the next visit still gets the saved copy, but the server builds a new page in the background. The visit after that gets the new list.

## Benefits of ISG

- **Fast like static pages.** Most visitors get a saved file.
- **Fresh content without full rebuilds.** Changed pages update themselves.
- **Scales to many pages.** You do not need to build 100,000 pages up front. Rare pages are made on their first visit.
- **Low server work.** A page is rendered at most once per revalidate time, not once per visitor.

## ISG Architecture

### Static Generation with Revalidation

Each page is a cache entry: its **HTML**, **when it was generated**, and a **revalidate** time in seconds. In Next.js you write `export const revalidate = 60;` in a page file. The page is then treated as static for 60 seconds at a time.

### Incremental Page Updates

When a request comes in after the time is up, only **that** page is regenerated. Other pages stay as they are. A site with 10,000 articles never needs a full rebuild to update one article.

### Content Refresh Workflow

ISG uses a pattern called **stale-while-revalidate**:

1. A visitor asks for a page whose time is up (it is **stale**).
2. The server sends the **old** page at once, so the visitor does not wait.
3. In the background, the server fetches the data and renders a **new** page.
4. The new page replaces the old one in the cache. The **next** visitor gets it.

<figure className="react-fig">
<svg viewBox="0 0 520 170" role="img" aria-labelledby="fig-isg-swr" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-isg-swr">Stale-while-revalidate: the visitor gets the saved page at once, and the server makes a fresh copy in the background</title>
  <rect x="5" y="20" width="100" height="46" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="55.0" y="40.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Visitor</text>
  <text x="55.0" y="57.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>GET /news</text>
  <path d="M105 43 L145 43" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M145 43 L136.7 46.5 L136.7 39.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="150" y="20" width="120" height="46" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="210.0" y="40.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Cache</text>
  <text x="210.0" y="57.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>page v1 (old)</text>
  <path d="M270 43 L310 43" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M310 43 L301.7 46.5 L301.7 39.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="315" y="20" width="95" height="46" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="362.5" y="40.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Visitor</text>
  <text x="362.5" y="57.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>gets v1 now</text>
  <path d="M210 66 L210 100" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M210 100 L206.5 91.7 L213.5 91.7 Z" style={{ fill: "var(--faint)" }} />
  <rect x="150" y="104" width="120" height="46" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="210.0" y="124.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Regenerate</text>
  <text x="210.0" y="141.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>in background</text>
  <path d="M270 127 L315 127" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M315 127 L306.7 130.5 L306.7 123.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="320" y="104" width="190" height="46" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="415.0" y="124.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Cache now has v2</text>
  <text x="415.0" y="141.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>next visitor gets it</text>
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="2.4s" repeatCount="indefinite" path="M105 43 L145 43 M270 43 L310 43" />
  </circle>
  <circle r="6" style={{ fill: "var(--info)" }}>
    <animateMotion dur="3s" repeatCount="indefinite" path="M210 66 L210 100 M270 127 L315 127" />
  </circle>
</svg>
<figcaption>Nobody waits for the regeneration. The next visitor sees the new page.</figcaption>
</figure>

Try it in the simulator. The page has a revalidate time of 10 seconds. Change the article in the "editor", then send requests and watch the cache. The clock runs 1 second per second, and **Skip 5 s** moves it forward.

      <p>
        Clock: <b>{now}s</b> · Database: article <b>v{source}</b> · Cache: page <b>v{cache.version}</b>
      </p>
      <svg viewBox="0 0 320 40" style={{ width: "100%" }}>
        <rect x={10} y={10} width={300} height={16} rx={8} fill="#eee" />
        <rect x={10} y={10} width={width} height={16} rx={8} fill={stale ? "#e07b00" : "#1e7b34"} />
        <text x={160} y={23} textAnchor="middle" fontSize={11} fill="#111">
          {regenUntil !== null ? "regenerating..." : stale ? "STALE (" + age + "s old)" : "FRESH (" + age + "s of " + REVALIDATE + "s)"}
        </text>
      </svg>
      <p>
        <button onClick={() => { setSource(source + 1); add("t=" + now + "s editor: article changed to v" + (source + 1)); }}>
          ✏️ Editor: change article
        </button>{" "}
        <button onClick={request}>🌐 Visitor: request page</button>{" "}
        <button onClick={() => setNow(now + 5)}>⏩ Skip 5 s</button>
      </p>
      <ul style={{ fontFamily: "monospace", fontSize: 12, paddingLeft: 18 }}>
        {log.map((line, i) => <li key={i}>{line}</li>)}
      </ul>
    </div>
  );
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

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

Notice: after the editor changes the article, the first stale request still gets the **old** version. Only the request after the regeneration gets the new one. This short delay is the price of never making visitors wait.

Easy Way to Remember: serve the old page now, bake a fresh one for the next visitor.

## ISG Rendering Fundamentals

### Build-Time Rendering

At build time, ISG works like SSG: it renders the pages you list. Usually these are the **most popular** ones, such as the home page and the latest articles. You do not have to list every page.

### Background Regeneration

After deployment, pages are regenerated **while the site runs**, in the background, on the server. The server fetches fresh data and renders the page again, exactly like a build, but for one page.

Pages that were **not** built (an old article, a new one) are made on their **first visit**. That first visitor waits once, like SSR. After that, the page is cached like any other.

### Serving Updated Pages

Once a new version is in the cache, every visitor gets it, until it becomes stale again. The cache entry has a **version** number, so you can see when a page changed.

The lab uses the real ISG cache from the capstone (`isg.ts`). It builds the home page and article 1. Watch the console as you request pages: MISS (made on first visit), HIT (fresh) and STALE (old page served, refresh started).

      <p>{built ? "Build done: / and /articles/1 are in the cache." : "Building..."}</p>
      {["/", "/articles/1", "/articles/3"].map((p) => (
        <button key={p} disabled={!built} onClick={() => ask(p)}>Request {p}</button>
      ))}
      <p style={{ fontFamily: "monospace" }}>{result}</p>
      <p style={{ color: "#666" }}>Tip: wait 5 seconds, then request a page again to see STALE.</p>
    </div>
  );
}
`,
    "isg.ts": `// A tiny ISG cache: serve saved HTML, and regenerate it in the background
// when it is older than "revalidate" milliseconds (stale-while-revalidate).
  html: string;
  generatedAt: number;
  version: number;
}

  const cache = new Map<string, CacheEntry>();
  const regenerating = new Set<string>();

  async function regenerate(path: string) {
    const html = await render(path);
    const old = cache.get(path);
    cache.set(path, { html, generatedAt: Date.now(), version: (old?.version ?? 0) + 1 });
    console.log("[ISG] generated " + path + " (v" + cache.get(path)!.version + ")");
  }

  async function request(path: string): Promise<{ entry: CacheEntry; status: CacheStatus }> {
    const entry = cache.get(path);
    if (!entry) {
      // Not built yet: generate it now, then keep it (on-demand generation)
      await regenerate(path);
      return { entry: cache.get(path)!, status: "MISS" };
    }
    if (Date.now() - entry.generatedAt < revalidateMs) return { entry, status: "HIT" };

    // Too old: serve the old page now, and refresh it in the background
    if (!regenerating.has(path)) {
      regenerating.add(path);
      regenerate(path).finally(() => regenerating.delete(path));
    }
    return { entry, status: "STALE" };
  }

  return {
    request,
    // build time: generate a list of pages before any visitor comes
    build: async (paths: string[]) => {
      for (const p of paths) await regenerate(p);
    },
    // on-demand revalidation: refresh one page now (for example after an edit)
    revalidate: regenerate,
  };
}
`,
    "render.tsx": `import { renderToStaticMarkup } from "react-dom/server";

// Fetch the data for a path and render it to HTML (what the server does)
  const match = /^\\/articles\\/(\\d+)$/.exec(path);
  if (match) {
    const response = await fetch(API + "/" + match[1] + "?delay=300");
    if (!response.ok) return "<p>Article not found.</p>";
    const article: Article = await response.json();
    return renderToStaticMarkup(<ArticlePage article={article} />);
  }
  const response = await fetch(API + "?delay=300");
  const articles: Article[] = await response.json();
  return renderToStaticMarkup(<NewsList articles={articles} />);
}
`,
    "pages.tsx": `import type { Article } from "./types";

  return (
    <section>
      <h2>Top stories</h2>
      {articles.map((a) => (
        <article key={a.id} className="item">
          <a href={"/articles/" + a.id}>{a.title}</a>
          <span className="cat">{a.category}</span>
          <p>{a.summary}</p>
        </article>
      ))}
    </section>
  );
}

  return (
    <article>
      <span className="cat">{article.category}</span>
      <h2>{article.title}</h2>
      <p>{article.body}</p>
      <a href="/">← All stories</a>
    </article>
  );
}
`,
    "types.ts": `export interface Article {
  id: number;
  title: string;
  category: string;
  summary: string;
  body: string;
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

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

A HIT takes about 0 ms. A MISS takes as long as fetching and rendering, about 300 ms here. That gap is why ISG builds the popular pages first.

## Content Revalidation

### Revalidation Process

**Revalidation** means checking if a cached page is still good, and making a new one if not. For each request, the server asks:

- Is the page in the cache? If **no** → generate it now (MISS).
- Is it younger than the revalidate time? If **yes** → send it (HIT).
- Otherwise → send it, and regenerate in the background (STALE).

<figure className="react-fig">
<svg viewBox="0 0 520 130" role="img" aria-labelledby="fig-isg-life" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-isg-life">The update lifecycle of one page: fresh, stale, regenerating, fresh again</title>
  <rect x="5" y="30" width="118" height="52" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="64.0" y="53.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Fresh</text>
  <text x="64.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>served from cache</text>
  <rect x="133" y="30" width="118" height="52" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="192.0" y="53.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Stale</text>
  <text x="192.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>time is up</text>
  <rect x="261" y="30" width="118" height="52" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="320.0" y="53.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Regenerate</text>
  <text x="320.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>in background</text>
  <rect x="389" y="30" width="118" height="52" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="448.0" y="53.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>Fresh again</text>
  <text x="448.0" y="70.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>new version</text>
  <path d="M123 56 L133 56" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M133 56 L124.7 59.5 L124.7 52.5 Z" style={{ fill: "var(--faint)" }} />
  <path d="M251 56 L261 56" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M261 56 L252.7 59.5 L252.7 52.5 Z" style={{ fill: "var(--faint)" }} />
  <path d="M379 56 L389 56" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M389 56 L380.7 59.5 L380.7 52.5 Z" style={{ fill: "var(--faint)" }} />
  <rect x="5" y="30" width="118" 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.6s" begin="0.0s" repeatCount="indefinite" />
  </rect>
  <rect x="133" y="30" width="118" 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.6s" begin="0.9s" repeatCount="indefinite" />
  </rect>
  <rect x="261" y="30" width="118" 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.6s" begin="1.8s" repeatCount="indefinite" />
  </rect>
  <rect x="389" y="30" width="118" 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.6s" begin="2.7s" repeatCount="indefinite" />
  </rect>
  <text x="260" y="110" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 12, fontWeight: 400, fontFamily: "var(--sans)" }}>the loop repeats for as long as the site runs</text>
</svg>
<figcaption>Each page has its own clock.</figcaption>
</figure>

### Data Refresh Strategies

There are three common ways to refresh content:

- **Time-based revalidation.** The page refreshes after N seconds. It is simple, but changes appear a little late.
- **On-demand revalidation.** Your app tells the server "refresh this page now", usually right after an editor saves. In Next.js this is `revalidatePath("/articles/2")`. Changes appear at once.
- **Generate on first visit.** Pages that were never built are made the first time someone asks for them.

<figure className="react-fig">
<svg viewBox="0 0 520 140" role="img" aria-labelledby="fig-isg-strategies" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-isg-strategies">Data refresh strategies: time-based, on-demand, and on-demand generation of new pages</title>
  <rect x="5" y="15" width="165" height="60" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="87.5" y="42.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>Time-based</text>
  <text x="87.5" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>revalidate: 60</text>
  <rect x="178" y="15" width="165" height="60" rx="8" style={{ fill: "var(--accent-secondary-soft)", stroke: "var(--accent-secondary)", strokeWidth: 1.5 }} />
  <text x="260.5" y="42.0" textAnchor="middle" style={{ fill: "var(--accent-secondary)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>On-demand</text>
  <text x="260.5" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>revalidate(path)</text>
  <rect x="351" y="15" width="165" height="60" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="433.5" y="42.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>On first visit</text>
  <text x="433.5" y="59.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>generate new pages</text>
  <text x="87" y="98" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>refresh after N seconds</text>
  <text x="260" y="98" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>refresh right after an edit</text>
  <text x="433" y="98" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>pages not built yet</text>
  <text x="87" y="116" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>simple, a little late</text>
  <text x="260" y="116" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>instant, needs a hook</text>
  <text x="433" y="116" textAnchor="middle" style={{ fill: "var(--muted)", fontSize: 11, fontWeight: 400, fontFamily: "var(--sans)" }}>first visitor waits once</text>
</svg>
<figcaption>Real sites usually combine all three.</figcaption>
</figure>

### Managing Frequently Updated Content

Pick the revalidate time for each **kind** of page:

<div className="tscroll">

| Page | Changes | Good revalidate time |
|---|---|---|
| Breaking news, live scores | Every few seconds | Short (10–30 s), or use SSR instead |
| Home page, article lists | Many times a day | 30–60 s, plus on-demand |
| Single articles | Rarely after publishing | 1 hour or more, plus on-demand on edit |
| About, contact pages | Almost never | 1 day, or plain SSG |

</div>

The lab compares time-based and on-demand refresh. Change the title and publish. With on-demand off, the page updates only after the time is up. With it on, the page is regenerated right after publishing.

      <input value={title} onChange={(e) => setTitle(e.target.value)} style={{ width: "100%" }} />
      <label>
        <input type="checkbox" checked={onDemand} onChange={(e) => setOnDemand(e.target.checked)} /> On-demand revalidation
      </label>{" "}
      <button onClick={publish}>Publish</button> <button onClick={() => visit("/articles/2")}>Visit article 2</button>
      <p style={{ fontFamily: "monospace" }}>{info}</p>
      <div onClick={handleClick} style={{ border: "1px solid #ccc", padding: 8, borderRadius: 8 }} dangerouslySetInnerHTML={{ __html: html }} />
    </div>
  );
}
`,
    "isg.ts": `// A tiny ISG cache: serve saved HTML, and regenerate it in the background
// when it is older than "revalidate" milliseconds (stale-while-revalidate).
  html: string;
  generatedAt: number;
  version: number;
}

  const cache = new Map<string, CacheEntry>();
  const regenerating = new Set<string>();

  async function regenerate(path: string) {
    const html = await render(path);
    const old = cache.get(path);
    cache.set(path, { html, generatedAt: Date.now(), version: (old?.version ?? 0) + 1 });
    console.log("[ISG] generated " + path + " (v" + cache.get(path)!.version + ")");
  }

  async function request(path: string): Promise<{ entry: CacheEntry; status: CacheStatus }> {
    const entry = cache.get(path);
    if (!entry) {
      // Not built yet: generate it now, then keep it (on-demand generation)
      await regenerate(path);
      return { entry: cache.get(path)!, status: "MISS" };
    }
    if (Date.now() - entry.generatedAt < revalidateMs) return { entry, status: "HIT" };

    // Too old: serve the old page now, and refresh it in the background
    if (!regenerating.has(path)) {
      regenerating.add(path);
      regenerate(path).finally(() => regenerating.delete(path));
    }
    return { entry, status: "STALE" };
  }

  return {
    request,
    // build time: generate a list of pages before any visitor comes
    build: async (paths: string[]) => {
      for (const p of paths) await regenerate(p);
    },
    // on-demand revalidation: refresh one page now (for example after an edit)
    revalidate: regenerate,
  };
}
`,
    "render.tsx": `import { renderToStaticMarkup } from "react-dom/server";

// Fetch the data for a path and render it to HTML (what the server does)
  const match = /^\\/articles\\/(\\d+)$/.exec(path);
  if (match) {
    const response = await fetch(API + "/" + match[1] + "?delay=300");
    if (!response.ok) return "<p>Article not found.</p>";
    const article: Article = await response.json();
    return renderToStaticMarkup(<ArticlePage article={article} />);
  }
  const response = await fetch(API + "?delay=300");
  const articles: Article[] = await response.json();
  return renderToStaticMarkup(<NewsList articles={articles} />);
}
`,
    "pages.tsx": `import type { Article } from "./types";

  return (
    <section>
      <h2>Top stories</h2>
      {articles.map((a) => (
        <article key={a.id} className="item">
          <a href={"/articles/" + a.id}>{a.title}</a>
          <span className="cat">{a.category}</span>
          <p>{a.summary}</p>
        </article>
      ))}
    </section>
  );
}

  return (
    <article>
      <span className="cat">{article.category}</span>
      <h2>{article.title}</h2>
      <p>{article.body}</p>
      <a href="/">← All stories</a>
    </article>
  );
}
`,
    "types.ts": `export interface Article {
  id: number;
  title: string;
  category: string;
  summary: string;
  body: string;
}
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

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

## Caching and Updates

### Cache Behavior

The ISG cache lives on the server (and often on a CDN in front of it). Each path has **its own** entry and its own clock. A visitor never sees "half" a page: they get either the old version or the new one.

### Content Consistency

Because pages refresh one by one, two pages can show **different versions** of the same data for a short time. For example, the article page shows the new title, but the home page list still shows the old one.

To keep related pages consistent, revalidate them **together**. After editing article 2, refresh both `/articles/2` and `/` (which lists it).

ISG content can be up to one revalidate period old. That is fine for news and shops, but not for things that must always be exact, like a bank balance or stock in a cart. Use SSR or client-side fetching for those.

### Update Lifecycle

Putting it together, a page goes through this loop:

1. **Built** at deploy time (or on the first visit).
2. **Fresh:** served from the cache, very fast.
3. **Stale:** the time is up. Still served, but a refresh starts.
4. **Regenerated:** the new version replaces the old one.
5. Back to **fresh**. An on-demand revalidation can jump straight to step 4 at any time.

## Advantages and Limitations of ISG

<div className="tscroll">

| Advantages | Limitations |
|---|---|
| Static speed for most visitors | Content can be up to one revalidate period old |
| Fresh content without full rebuilds | Needs a server or a platform that supports it (not plain static hosting) |
| Builds stay fast: rare pages are made on first visit | Related pages can briefly disagree |
| Server renders each page at most once per period | Harder to debug: "which version am I seeing?" |
| Edits can show at once with on-demand revalidation | First visit to an unbuilt page is slower |

</div>

<div className="tscroll">

| | SSG | ISG | SSR |
|---|---|---|---|
| When HTML is made | Build time | Build time + background refresh | Every request |
| Speed | Fastest | Fast (cached) | Slower |
| Freshness | Until the next build | Within the revalidate time | Always fresh |
| Hosting | Static files | Server or ISG platform | Server |

</div>

## Capstone Project: News Portal

You will build **The Book Corner Times**, a small news portal. Articles come from the pretend API at `https://api.bookcorner.dev/articles`. Pages are generated statically, cached with ISG, and refreshed when the newsroom edits a story.

<figure className="react-fig">
<svg viewBox="0 0 520 140" role="img" aria-labelledby="fig-isg-news" style={{ width: "100%", maxWidth: 520, height: "auto" }}>
  <title id="fig-isg-news">News Portal: listing page and article pages, each with its own cache entry and revalidate time</title>
  <rect x="10" y="15" width="150" height="46" rx="8" style={{ fill: "var(--accent-soft)", stroke: "var(--accent)", strokeWidth: 1.5 }} />
  <text x="85.0" y="35.0" textAnchor="middle" style={{ fill: "var(--accent)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/</text>
  <text x="85.0" y="52.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>top stories</text>
  <rect x="185" y="15" width="150" height="46" rx="8" style={{ fill: "var(--ok-soft)", stroke: "var(--ok)", strokeWidth: 1.5 }} />
  <text x="260.0" y="35.0" textAnchor="middle" style={{ fill: "var(--ok)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/articles/1</text>
  <text x="260.0" y="52.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>built</text>
  <rect x="360" y="15" width="150" height="46" rx="8" style={{ fill: "var(--info-soft)", stroke: "var(--info)", strokeWidth: 1.5 }} />
  <text x="435.0" y="35.0" textAnchor="middle" style={{ fill: "var(--info)", fontSize: 13, fontWeight: 700, fontFamily: "var(--mono)" }}>/articles/3</text>
  <text x="435.0" y="52.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 11, fontFamily: "var(--sans)" }}>on first visit</text>
  <rect x="10" y="85" width="500" height="40" rx="8" style={{ fill: "var(--panel2)", stroke: "var(--faint)", strokeWidth: 1.5 }} />
  <text x="260.0" y="110.0" textAnchor="middle" style={{ fill: "var(--ink2)", fontSize: 13, fontWeight: 700, fontFamily: "var(--sans)" }}>ISG cache: each path keeps its HTML, its age and its version</text>
  <path d="M85 61 L85 82" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M85 82 L81.5 73.7 L88.5 73.7 Z" style={{ fill: "var(--faint)" }} />
  <path d="M260 61 L260 82" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M260 82 L256.5 73.7 L263.5 73.7 Z" style={{ fill: "var(--faint)" }} />
  <path d="M435 61 L435 82" style={{ stroke: "var(--faint)", strokeWidth: 2, fill: "none" }} />
  <path d="M435 82 L431.5 73.7 L438.5 73.7 Z" style={{ fill: "var(--faint)" }} />
  <circle r="6" style={{ fill: "var(--accent-secondary)" }}>
    <animateMotion dur="2.4s" repeatCount="indefinite" path="M85 61 L85 82 M260 61 L260 82 M435 61 L435 82" />
  </circle>
</svg>
<figcaption>The newsroom edits data. The cache turns it into pages.</figcaption>
</figure>

### Create article listing pages

`NewsList` (in `pages.tsx`) shows each article's title as a link, its category and a short summary.

### Create article details pages

`ArticlePage` shows one article with its category, title and full text, and a link back to the list.

### Generate pages statically

`render.tsx` has `renderPage(path)`: it fetches the data for the path and turns the page into HTML with `renderToStaticMarkup`. At "build time", the app calls `isg.build(["/", "/articles/1", "/articles/2"])`. Article 3 is not built; it is made on its first visit. In the lab, click a story on the left to render its page on the right.

      <div className="reader">
        <div className="masthead">/ (listing)</div>
        <div dangerouslySetInnerHTML={{ __html: list || "<p>Building...</p>" }} />
      </div>
      <div className="reader">
        <div className="masthead">{path}</div>
        <div dangerouslySetInnerHTML={{ __html: detail || "<p>Building...</p>" }} />
      </div>
    </div>
  );
}
`,
    "render.tsx": `import { renderToStaticMarkup } from "react-dom/server";

// Fetch the data for a path and render it to HTML (what the server does)
  const match = /^\\/articles\\/(\\d+)$/.exec(path);
  if (match) {
    const response = await fetch(API + "/" + match[1] + "?delay=300");
    if (!response.ok) return "<p>Article not found.</p>";
    const article: Article = await response.json();
    return renderToStaticMarkup(<ArticlePage article={article} />);
  }
  const response = await fetch(API + "?delay=300");
  const articles: Article[] = await response.json();
  return renderToStaticMarkup(<NewsList articles={articles} />);
}
`,
    "pages.tsx": `import type { Article } from "./types";

  return (
    <section>
      <h2>Top stories</h2>
      {articles.map((a) => (
        <article key={a.id} className="item">
          <a href={"/articles/" + a.id}>{a.title}</a>
          <span className="cat">{a.category}</span>
          <p>{a.summary}</p>
        </article>
      ))}
    </section>
  );
}

  return (
    <article>
      <span className="cat">{article.category}</span>
      <h2>{article.title}</h2>
      <p>{article.body}</p>
      <a href="/">← All stories</a>
    </article>
  );
}
`,
    "types.ts": `export interface Article {
  id: number;
  title: string;
  category: string;
  summary: string;
  body: string;
}
`,
    "styles.css": `.portal { font-family: Georgia, serif; display: flex; gap: 12px; flex-wrap: wrap; }
.reader { flex: 2; min-width: 260px; border: 2px solid #222; border-radius: 8px; padding: 8px 12px; }
.newsroom { flex: 1; min-width: 200px; background: #fff8e6; border-radius: 8px; padding: 8px 12px; font-family: sans-serif; font-size: 13px; }
.masthead { font-size: 20px; font-weight: bold; border-bottom: 3px double #222; margin-bottom: 6px; }
.bar { font-family: monospace; font-size: 12px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.badge { padding: 1px 6px; border-radius: 4px; color: white; }
.badge.HIT { background: #1e7b34; } .badge.STALE { background: #e07b00; } .badge.MISS { background: #2b59c3; }
.item { border-bottom: 1px solid #ddd; padding: 4px 0; }
.item a { font-weight: bold; color: #111; }
.cat { font-family: sans-serif; font-size: 11px; text-transform: uppercase; color: #b42318; margin-left: 6px; }
.updated { background: #e3f6e8; color: #1e7b34; padding: 2px 6px; border-radius: 4px; font-family: sans-serif; font-size: 12px; }
.newsroom input { width: 100%; padding: 4px; margin: 4px 0; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

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

### Implement content revalidation

`isg.ts` is the cache. `request(path)` returns the page and a status: MISS, HIT or STALE. The portal uses a revalidate time of 8 seconds, so you can see it work quickly. A real site would use minutes.

### Manage frequently updated articles

The **Newsroom** panel edits article 2 through the API (a `PUT` request). With **Revalidate now** ticked, it also calls `isg.revalidate()` for both `/articles/2` and `/`, so the article and the list stay consistent. Without it, the change shows up after the next stale request.

### Display refreshed content automatically

The reader view asks the server for the current page every 3 seconds, like a news site that checks for updates. When the version number goes up, it shows **Updated just now**. The bar shows the cache status, the version and the page's age.

Here is the finished portal. Open article 2, change its title in the Newsroom, and publish, with and without on-demand revalidation.

      <div className="reader">
        <div className="masthead">The Book Corner Times</div>
        <div className="bar">
          <span>{path}</span>
          <span className={"badge " + page.status}>{page.status}</span>
          <span>v{page.version} · {age}s old · revalidate {REVALIDATE_SECONDS}s</span>
          {updated && <span className="updated">Updated just now</span>}
        </div>
        <div onClick={handleClick} dangerouslySetInnerHTML={{ __html: page.html }} />
      </div>
      <div className="newsroom">
        <b>📝 Newsroom</b>
        <p>Edit article 2 and publish. Readers get the new page after it is regenerated.</p>
        <input value={title} onChange={(e) => setTitle(e.target.value)} />
        <label>
          <input type="checkbox" style={{ width: "auto" }} checked={onDemand} onChange={(e) => setOnDemand(e.target.checked)} />{" "}
          Revalidate now (on-demand)
        </label>
        <button onClick={publish}>Publish</button>
      </div>
    </div>
  );
}
`,
    "isg.ts": `// A tiny ISG cache: serve saved HTML, and regenerate it in the background
// when it is older than "revalidate" milliseconds (stale-while-revalidate).
  html: string;
  generatedAt: number;
  version: number;
}

  const cache = new Map<string, CacheEntry>();
  const regenerating = new Set<string>();

  async function regenerate(path: string) {
    const html = await render(path);
    const old = cache.get(path);
    cache.set(path, { html, generatedAt: Date.now(), version: (old?.version ?? 0) + 1 });
    console.log("[ISG] generated " + path + " (v" + cache.get(path)!.version + ")");
  }

  async function request(path: string): Promise<{ entry: CacheEntry; status: CacheStatus }> {
    const entry = cache.get(path);
    if (!entry) {
      // Not built yet: generate it now, then keep it (on-demand generation)
      await regenerate(path);
      return { entry: cache.get(path)!, status: "MISS" };
    }
    if (Date.now() - entry.generatedAt < revalidateMs) return { entry, status: "HIT" };

    // Too old: serve the old page now, and refresh it in the background
    if (!regenerating.has(path)) {
      regenerating.add(path);
      regenerate(path).finally(() => regenerating.delete(path));
    }
    return { entry, status: "STALE" };
  }

  return {
    request,
    // build time: generate a list of pages before any visitor comes
    build: async (paths: string[]) => {
      for (const p of paths) await regenerate(p);
    },
    // on-demand revalidation: refresh one page now (for example after an edit)
    revalidate: regenerate,
  };
}
`,
    "render.tsx": `import { renderToStaticMarkup } from "react-dom/server";

// Fetch the data for a path and render it to HTML (what the server does)
  const match = /^\\/articles\\/(\\d+)$/.exec(path);
  if (match) {
    const response = await fetch(API + "/" + match[1] + "?delay=300");
    if (!response.ok) return "<p>Article not found.</p>";
    const article: Article = await response.json();
    return renderToStaticMarkup(<ArticlePage article={article} />);
  }
  const response = await fetch(API + "?delay=300");
  const articles: Article[] = await response.json();
  return renderToStaticMarkup(<NewsList articles={articles} />);
}
`,
    "pages.tsx": `import type { Article } from "./types";

  return (
    <section>
      <h2>Top stories</h2>
      {articles.map((a) => (
        <article key={a.id} className="item">
          <a href={"/articles/" + a.id}>{a.title}</a>
          <span className="cat">{a.category}</span>
          <p>{a.summary}</p>
        </article>
      ))}
    </section>
  );
}

  return (
    <article>
      <span className="cat">{article.category}</span>
      <h2>{article.title}</h2>
      <p>{article.body}</p>
      <a href="/">← All stories</a>
    </article>
  );
}
`,
    "types.ts": `export interface Article {
  id: number;
  title: string;
  category: string;
  summary: string;
  body: string;
}
`,
    "styles.css": `.portal { font-family: Georgia, serif; display: flex; gap: 12px; flex-wrap: wrap; }
.reader { flex: 2; min-width: 260px; border: 2px solid #222; border-radius: 8px; padding: 8px 12px; }
.newsroom { flex: 1; min-width: 200px; background: #fff8e6; border-radius: 8px; padding: 8px 12px; font-family: sans-serif; font-size: 13px; }
.masthead { font-size: 20px; font-weight: bold; border-bottom: 3px double #222; margin-bottom: 6px; }
.bar { font-family: monospace; font-size: 12px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.badge { padding: 1px 6px; border-radius: 4px; color: white; }
.badge.HIT { background: #1e7b34; } .badge.STALE { background: #e07b00; } .badge.MISS { background: #2b59c3; }
.item { border-bottom: 1px solid #ddd; padding: 4px 0; }
.item a { font-weight: bold; color: #111; }
.cat { font-family: sans-serif; font-size: 11px; text-transform: uppercase; color: #b42318; margin-left: 6px; }
.updated { background: #e3f6e8; color: #1e7b34; padding: 2px 6px; border-radius: 4px; font-family: sans-serif; font-size: 12px; }
.newsroom input { width: 100%; padding: 4px; margin: 4px 0; }
`,
    "main.tsx": `import { createRoot } from "react-dom/client";

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

## Download Project Files

### Download ISG Project Source Code

The complete News Portal is ready as a real ISG project with **Vite**, **React**, **TypeScript**, **React Router** and a small **Express** server. The server keeps an ISG cache: pages are generated at start-up ("build"), served from the cache, regenerated in the background after their revalidate time, and refreshed at once when you publish in the Newsroom.

Download ISG Project: [Download News Portal Project](/downloads/react/isg-news-portal.zip)

```
news-portal/
  package.json            scripts and dependencies
  server.js               Express: ISG cache, revalidation, JSON API
  isg.config.js           revalidate times per route
  data/articles.json      the articles (the "database")
  index.html              template with <!--app-html--> and <!--app-data-->
  src/
    entry-server.tsx      render(url): load data + renderToString
    entry-client.tsx      hydrateRoot with the page data
    App.tsx               routes
    routes.ts             route matching and loadPageData()
    server/articlesStore.ts  read and write data/articles.json
    pages/                NewsListPage, ArticlePage, NewsroomPage, NotFoundPage
    components/           Masthead, ArticleCard, CacheInfo
    hooks/useAutoRefresh.ts  check for a newer version and update the page
    types/news.ts
```

### 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 `news-portal`.
3. Open a terminal in that folder (in VS Code: File, Open Folder, then Terminal, New Terminal).
4. Install the packages and start the server:

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

Open `http://localhost:5173`. Open the **Newsroom** page in a second tab, edit a story, and watch the first tab update. The terminal logs every HIT, STALE and regeneration. For production, run `npm run build` and then `npm run start`.

In the browser's developer tools (F12), open the Network tab and click the page request. The response header `X-ISG-Cache` shows HIT, STALE or MISS, and `X-ISG-Age` shows how old the page is.

## Common mistakes

<div className="tscroll">

| Mistake | What you see | Fix |
|---|---|---|
| Expecting the first request after an edit to show the new content | The visitor still sees the old page | That is stale-while-revalidate; the next request gets the new page, or use on-demand revalidation |
| A very short revalidate time on every page | The server renders almost as often as SSR | Use short times only for pages that change often |
| Revalidating only the edited article | The home page list shows the old title | Revalidate every page that shows the changed data |
| Building every page at deploy time | Very slow builds on big sites | Build popular pages; make the rest on first visit |
| Using ISG for per-user or exact data | Users see someone else's or old data | Use SSR or client fetching for carts, balances, dashboards |
| Hosting an ISG site as plain static files | Pages never refresh | ISG needs a server or a platform that supports it |

</div>

## Check Yourself

## Exercises

## What to Remember

- ISG is static generation plus revalidation: pages are cached HTML, and each one is regenerated after its revalidate time.
- Stale-while-revalidate: a stale page is still served at once, and the fresh copy goes to the next visitor. Pages not built yet are made on their first visit.
- Use time-based revalidation for regular refresh, and on-demand revalidation right after edits. Revalidate related pages together. ISG needs a server or an ISG-ready platform.

Next: API Integration
