Incremental Static Regeneration (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). 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.

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

Second request and beyond (cache hit):

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 below for a detailed comparison of the available strategies.

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

// 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 for details.

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.

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.

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.

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.

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.

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.

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

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

import { createUniformStaticParams } from "@uniformdev/next-app-router"; // Pre-render these paths at build time export const generateStaticParams = async () => { return createUniformStaticParams({ paths: ["/", "/about", "/contact"], }); }; ...

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 (release-candidate branch).

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:

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); }

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:

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:

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 }); };

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:

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

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.

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.

The POST handler responds to these Uniform webhook events automatically:

EventWhat gets revalidated
composition:publishedAll paths linked to the composition via project map
composition:deletedAll paths linked to the composition via project map
projectMapNode:insertThe inserted node's path
projectMapNode:updateBoth the current and previous path (handles renames/moves)
projectMapNode:deleteThe deleted node's path
redirect:insertThe redirect source URL path
redirect:updateThe redirect source URL path
redirect:deleteThe redirect source URL path
manifest:publishedThe 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.


If you followed the setup guide, you already have this file. The POST handler serves double duty -- it handles both visual editing preview requests and webhook-based revalidation:

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

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:

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

  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.

For additional security, Uniform webhooks support Svix 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:

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

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.


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

ProviderSupport
VercelFull support (including revalidateTag with edge caching)
Self-hostedSupported when using the Next.js standalone output with a persistent cache directory
NetlifySupported via Netlify's Next.js runtime
CloudflareSupported via Open Next adapter

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


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

import { revalidatePath } from "next/cache"; // Correct: use the original path revalidatePath("/about"); // Incorrect: do NOT use the internal code path // revalidatePath("/uniform/eyJyb3V0ZVBhdGgi...");

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.