# Incremental Static Regeneration (ISR)

> How to configure ISR with the Uniform SDK for Next.js App Router, including on-demand revalidation via Uniform webhooks.

Source: https://docs.uniform.app/docs/sdk/nextjs-app-router/incremental-static-regeneration-isr

## Static generation (ISR)

By default, Uniform pages are rendered **on demand per request** (dynamic rendering). This is because the Uniform middleware evaluates personalization and A/B tests at the edge for each visitor, producing a unique `code` (serialized page state) that the `app/uniform/[code]/page.tsx` route renders. Since each visitor may receive a different variant, the page cannot be statically generated without knowing all possible variant combinations upfront.

As an **optional, yet recommended optimization**, you can use `generateStaticParams` with `createUniformStaticParams` to pre-render specific pages at build time using Next.js [Incremental Static Regeneration (ISR)](https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration). The SDK generates all possible page state permutations (accounting for personalization variants, A/B tests, and visibility rules) for the given paths, so every variant combination is pre-built.

This is useful for high-traffic pages where you want the fastest possible response time.

### How ISR works (recommended default)

The recommended starting point is ISR with **no build-time pre-rendering**. Pages are generated on their first visit and cached for all subsequent visitors. This gives you static-like performance with zero added build cost:

**First request** (cache miss -- page not yet generated):

```mermaid
flowchart TD
  A1["Visitor requests /about"] --> A2{"CDN cache?"}
  A2 -->|"Miss"| A3["Forward to origin server"]
  A3 --> A4["Render page on demand"]
  A4 --> A5["~200-500ms TTFB"]
  A4 --> A6["Cache result at CDN"]
```

**Second request and beyond** (cache hit):

```mermaid
flowchart TD
  B1["Visitor requests /about"] --> B2{"CDN cache?"}
  B2 -->|"Hit"| B3["Serve from edge PoP"]
  B3 --> B4["~10-50ms TTFB"]
```

This is enough for most sites. The only cost is a single cold-start for the very first visitor to hit each unique page state. After that, every visitor is served a static page from the nearest CDN edge node.

> **Note:**
>
> You can optimize first-visit latency further by pre-rendering high-traffic pages at build time. This trades faster builds for faster first renders. See [Understanding the trade-offs](#understanding-the-trade-offs) below for a detailed comparison of the available strategies.

### Enabling ISR (no build-time pre-rendering)

The simplest way to enable ISR is to add `generateStaticParams` that returns an empty array. No pages are pre-rendered at build time, but each page is statically generated on its **first request** and cached. Subsequent requests for the same page state are served from cache:

`/app/uniform/[code]/page.tsx`

```tsx
// Enable ISR: pages are generated on first visit and cached
export const generateStaticParams = async () => {
  return [];
};

...

```

This is often enough to get meaningful performance gains without the complexity of managing a path list.

> **Warning:**
>
> **`cacheComponents` incompatibility:** If your `next.config.ts` has `cacheComponents: true` (required for the `use cache` directive), Next.js does **not** allow an empty `generateStaticParams` -- the build will fail. You must return at least one param so Next.js can validate the route at build time. In that case, use one of the approaches below to provide actual paths.
>
> See the [Next.js documentation on this constraint](https://nextjs.org/docs/messages/empty-generate-static-params) for details.

### Pre-rendering specific paths

For higher performance, you can pre-render specific pages at build time using `createUniformStaticParams`. Pages not in the list are still generated on first request and cached (thanks to `dynamicParams` defaulting to `true`), but pre-rendered pages are available instantly from the first request.

### Understanding the trade-offs

Pre-rendering pages with personalization and A/B testing has a cost: the SDK must generate the **cartesian product** of all variant combinations for each path. A page with 3 personalization slots (2 variants each) and 1 A/B test (2 variants) produces 2 x 2 x 2 x 2 = **16 page permutations** -- all of which must be built. As you add more paths and more variants, build times and build output size grow multiplicatively.

This is the fundamental trade-off you should consider when deciding how many paths to pre-render.

#### Option A: No pre-rendering (recommended starting point)

```mermaid
flowchart TD
  A1["generateStaticParams returns empty"] --> A2["Build completes in seconds"]
  A2 --> A3["First visitor triggers render"]
  A3 --> A4["~200-500ms TTFB"]
  A4 --> A5["Cached for subsequent visitors"]
```

**Build time:** Fastest (seconds). **First visit:** On-demand render, then cached. **Best for:** Most sites, where a brief cold-start for the first visitor is acceptable.

#### Option B: Pre-render high-traffic pages

```mermaid
flowchart TD
  B1["generateStaticParams returns key paths"] --> B2["Build takes minutes"]
  B2 --> B3["Pre-rendered pages served instantly"]
  B3 --> B4["Other pages rendered on first visit"]
```

**Build time:** Moderate (minutes, depending on variant count). **First visit:** Instant for pre-rendered pages, on-demand for the rest. **Best for:** High-traffic pages (homepage, landing pages) where first-visit latency matters.

#### Option C: Pre-render all pages

```mermaid
flowchart TD
  C1["generateStaticParams returns all paths"] --> C2["Build takes 10+ minutes"]
  C2 --> C3["Every page served instantly"]
  C3 --> C4["Cost: pages x variants"]
```

**Build time:** Slowest (grows with pages x variants). **First visit:** Instant for every page. **Best for:** Small sites with few pages, or sites where every page must be fast from the very first request.

> **Note:**
>
> For most Uniform projects, **Option A or B** is the sweet spot. Pre-rendering every page on a site with many personalization variants can produce thousands of static pages, significantly increasing build time and deployment size. Start with no pre-rendering (Option A), measure first-visit latency, and selectively add high-traffic paths (Option B) only if needed.

### How `createUniformStaticParams` works

`createUniformStaticParams` takes an array of **original visitor-facing paths** (e.g., `/`, `/about`, `/contact`) and for each path:

1. Resolves the composition via the Uniform Route API.
2. Extracts all personalizations, A/B tests, and visibility rules from the composition tree.
3. Generates all possible variant permutations (the cartesian product of all variants).
4. Serializes each permutation into a `code` value for `generateStaticParams`.

> **Warning:**
>
> The `paths` array must contain **original visitor-facing paths** (e.g., `/about`), not the internal rewritten code path (`/uniform/some-code-here`). The SDK resolves the code values from these paths automatically.

### Basic example

Add `generateStaticParams` to your `app/uniform/[code]/page.tsx`:

`/app/uniform/[code]/page.tsx`

```tsx
import { createUniformStaticParams } from "@uniformdev/next-app-router";

// Pre-render these paths at build time
export const generateStaticParams = async () => {
  return createUniformStaticParams({
    paths: ["/", "/about", "/contact"],
  });
};
...
```

### Dynamic paths from the project map

Instead of hard-coding paths, you can fetch them programmatically from the Uniform project map. This ensures new pages are automatically included without code changes.

For a fully working example of this pattern, see the [Component Starter Kit](https://github.com/uniformdev/component-starter-kit-next-approuter/tree/release-candidate) (`release-candidate` branch).

```tsx
import {
  getProjectMapClient,
  createUniformStaticParams,
} from "@uniformdev/next-app-router";

async function getAllPaths(): Promise<string[]> {
  const client = getProjectMapClient({
    cache: { type: "default" },
  });

  const { nodes } = await client.getNodes({ tree: false });

  if (!nodes?.length) {
    return [];
  }

  return nodes.map((node) => node.path);
}

export const generateStaticParams = async () => {
  const paths = await getAllPaths();
  return createUniformStaticParams({ paths });
};
```

You can also combine programmatic and manual paths, or filter by specific criteria:

```tsx
async function getHighTrafficPaths(): Promise<string[]> {
  const client = getProjectMapClient({
    cache: { type: "default" },
  });

  const { nodes } = await client.getNodes({ tree: false });

  if (!nodes?.length) {
    return [];
  }

  // Only pre-render top-level pages (not deeply nested ones)
  return nodes
    .filter((node) => node.path.split("/").filter(Boolean).length <= 1)
    .map((node) => node.path);
}
```

### Localized paths

For localized sites, each locale/path combination must be included as a separate entry in the `paths` array. The `locale` parameter tells the SDK which locale to embed in the page state for route resolution:

```tsx
export const generateStaticParams = async () => {
  return createUniformStaticParams({
    paths: ["/en", "/en/about", "/fr", "/fr/about"],
    locale: "en",
  });
};
```

When fetching paths from the project map dynamically, expand each node into all supported locales:

```tsx
import { getProjectMapClient, createUniformStaticParams } from "@uniformdev/next-app-router";

const locales = ["en", "fr", "de"];

async function getAllLocalizedPaths(): Promise<string[]> {
  const client = getProjectMapClient({
    cache: { type: "default" },
  });

  const { nodes } = await client.getNodes({ tree: false });

  if (!nodes?.length) {
    return [];
  }

  // For each node, generate a path for each locale
  return nodes.flatMap((node) =>
    locales.map((locale) => `/${locale}${node.path === "/" ? "" : node.path}`)
  );
  // Produces: ["/en", "/en/about", "/fr", "/fr/about", "/de", "/de/about"]
}

export const generateStaticParams = async () => {
  const paths = await getAllLocalizedPaths();
  return createUniformStaticParams({ paths });
};
```

### Path rewriting

If your middleware uses `rewriteRequestPath` to transform paths before route resolution (for example, to prepend a locale), you can pass the same rewrite logic to `createUniformStaticParams` so it resolves routes the same way the middleware does:

```tsx
export const generateStaticParams = async () => {
  return createUniformStaticParams({
    paths: ["/", "/about", "/contact"],
    rewrite: async ({ path }) => ({
      path: `/en${path}`,
    }),
  });
};
```

---

## On-demand revalidation via webhooks

When content is published in Uniform, you want the cached static pages to update without a full site rebuild. The Uniform App Router SDK handles this automatically through **on-demand revalidation** -- Uniform sends a webhook to your Next.js app, and the SDK's `POST` handler invalidates the Next.js cache for the affected pages.

### How it works

```mermaid
flowchart TD
  A["Editor publishes content"] --> B["Uniform sends webhook POST"]
  B --> C{"Secret valid?"}
  C -->|"Yes"| D["Identify affected paths"]
  D --> E["Call revalidatePath / revalidateTag"]
  E --> F["Next.js purges cached pages"]
  F --> G["Next visitor gets fresh content"]
  C -->|"No"| H["401 Unauthorized"]
```

Unlike the old Pages Router approach (which required a custom `pages/api/revalidate.ts` handler with `res.revalidate()`), the App Router SDK handles everything through the built-in `POST` handler in `app/api/preview/route.ts`. The SDK:

1. Receives the webhook payload from Uniform.
2. Validates the request using either `UNIFORM_PREVIEW_SECRET` (query string) or `UNIFORM_WEBHOOK_SECRET` (Svix signature verification).
3. Parses the webhook event type (composition published, project map node updated, redirect changed, manifest published, etc.).
4. Looks up the affected project map node paths for the changed composition.
5. Calls `revalidatePath()` and `revalidateTag()` from `next/cache` to purge the Next.js cache for those paths.

### Supported webhook events

The `POST` handler responds to these Uniform webhook events automatically:

| Event | What gets revalidated |
| --- | --- |
| `composition:published` | All paths linked to the composition via project map |
| `composition:deleted` | All paths linked to the composition via project map |
| `projectMapNode:insert` | The inserted node's path |
| `projectMapNode:update` | Both the current and previous path (handles renames/moves) |
| `projectMapNode:delete` | The deleted node's path |
| `redirect:insert` | The redirect source URL path |
| `redirect:update` | The redirect source URL path |
| `redirect:delete` | The redirect source URL path |
| `manifest:published` | The `manifest` cache tag (personalization manifest) |

For pattern compositions (reusable across multiple pages), the SDK invalidates the `route` cache tag, which purges all route cache entries since a pattern change could affect any page.

---

### Step 1: Set up the preview/revalidation handler

If you followed the [setup guide](../nextjs-app-router#step-5-preview-handler-and-isr-handler), you already have this file. The `POST` handler serves double duty -- it handles both visual editing preview requests and webhook-based revalidation:

```ts
// app/api/preview/route.ts
import {
  createPreviewGETRouteHandler,
  createPreviewPOSTRouteHandler,
  createPreviewOPTIONSRouteHandler,
} from "@uniformdev/next-app-router/handler";

export const GET = createPreviewGETRouteHandler({
  resolveFullPath: ({ path }) => (path ? path : "/playground"),
});
export const POST = createPreviewPOSTRouteHandler();
export const OPTIONS = createPreviewOPTIONSRouteHandler();
```

No custom revalidation code is needed. The SDK handles parsing the webhook payload, resolving affected paths, and calling `revalidatePath()` and `revalidateTag()` internally.

### Step 2: Set the preview secret environment variable

Add a `UNIFORM_PREVIEW_SECRET` to your environment. This secret is passed as a query parameter in the webhook URL and validates that the revalidation request is coming from Uniform:

```bash
# .env.local (for local development)
UNIFORM_PREVIEW_SECRET=your-secret-value-here
```

Set this same value in your hosting provider's environment variables (Vercel, Netlify, etc.).

> **Note:**
>
> You can use any strong random string as the secret. Generate one with `openssl rand -base64 32` or similar. The secret must match between your environment variable and the webhook URL configured in Uniform.

### Step 3: Configure the webhook in Uniform

1. Go to your Uniform project in the dashboard.
2. Navigate to **Settings** -> **Webhooks**.
3. Click **Add webhook**.
4. Configure the webhook:

   - **URL**: `https://your-site.com/api/preview?secret=your-secret-value-here`
   - **Events**: Select the events you want to trigger revalidation for. At minimum, select `composition.published`. For full coverage, also select `composition.deleted`, `entry.published`, `entry.deleted`, `projectMapNode.update`, `redirect.insert`, `redirect.update`, `redirect.delete`, and `manifest.published`. If you plan on using the releases, toggle `release.launched`.
5. Save the webhook.

> **Warning:**
>
> Make sure not to select the `*.changed` webhook events when configuring the ISR handler as those fire more often during content authoring and will create unnecessary revalidation request. Specifically, ignore these webhook events:  `composition.changed` and `entry.changed`

> **Warning:**
>
> The secret is passed as a **query parameter** (`?secret=...`), not as a header. Make sure the value in the URL matches your `UNIFORM_PREVIEW_SECRET` environment variable exactly.

### Step 4: (Optional) Use Svix webhook signature verification

For additional security, Uniform webhooks support [Svix](https://www.svix.com/) signature verification. When enabled, each webhook request includes `svix-id`, `svix-timestamp`, and `svix-signature` headers. The SDK automatically validates these signatures when the `UNIFORM_WEBHOOK_SECRET` environment variable is set:

```bash
# .env.local
UNIFORM_WEBHOOK_SECRET=whsec_your-svix-signing-secret
```

You can find the Svix signing secret in your Uniform project's webhook settings. When `UNIFORM_WEBHOOK_SECRET` is set, the SDK verifies the request signature using the Svix library before processing the webhook payload. If the signature does not match, the request is rejected with a 401 response.

You can use **either** `UNIFORM_PREVIEW_SECRET` (query parameter) or `UNIFORM_WEBHOOK_SECRET` (Svix signature), or both for defense in depth.

### Verifying the setup

After configuring the webhook, publish a change to a composition in Uniform. You should see:

1. A `POST` request to your `/api/preview` endpoint in your server logs.
2. The response body includes `{ "handled": true, "tags": [...], "paths": [...] }` indicating which cache entries were invalidated.
3. The next visit to the affected page renders fresh content.

To test locally, you can use a tunnel service (like `ngrok` or `cloudflared`) to expose your local dev server to the internet, then configure the webhook URL to point to your tunnel URL.

---

### CDN provider compatibility

On-demand revalidation requires your hosting provider to support Next.js cache invalidation:

| Provider | Support |
| --- | --- |
| Vercel | Full support (including `revalidateTag` with edge caching) |
| Self-hosted | Supported when using the [Next.js standalone output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output#automatically-copying-traced-files) with a persistent cache directory |
| Netlify | Supported via Netlify's Next.js runtime |
| Cloudflare | Supported via Open Next adapter |

Consult your provider's documentation to ensure on-demand ISR is compatible with your deployment setup.

---

### Manual revalidation

If you need to trigger revalidation manually (for example, from a custom API route or a build script), use the original visitor-facing path:

```tsx
import { revalidatePath } from "next/cache";

// Correct: use the original path
revalidatePath("/about");

// Incorrect: do NOT use the internal code path
// revalidatePath("/uniform/eyJyb3V0ZVBhdGgi...");
```

### Playground route

You can also add `generateStaticParams` to the playground route using `createUniformPlaygroundStaticParams` in the same way.

> **Note:**
>
> Adding custom keys or data to page state creates new unique codes. If you do this, avoid pre-rendering those paths with `generateStaticParams` since the codes will not align at build time.

---
