Granular cache invalidation on publish (App Router)

Last updated: June 25, 2026

Stop a single composition publish from flushing the cache for every route. This article shows a drop-in custom preview POST handler that expands known dynamic inputs (locales, categories, …) and uses the Relationships API to resolve real pattern consumers, so root/route is only ever cleared as a last-resort safety net.

  • Applies to: Next.js App Router + @uniformdev/canvas-next-rsc

  • Packages used (all public): @uniformdev/canvas-next-rsc, @uniformdev/canvas, @uniformdev/project-map, @uniformdev/webhooks, svix

  • You provide: an API key with read access, and (optionally) a catalog source for non-locale segments


Background: how Uniform tags & revalidation work

The SDK clients tag every fetch so the Next.js Data Cache can be invalidated granularly. For a request to /en/shoes/sneaker-1 the route client emits one tag per path segment, plus a global route tag:

route
path:/
path:/en
path:/en/shoes
path:/en/shoes/sneaker-1

Because each page fetch carries all of its ancestor tags, revalidating path:/en clears everything under /en, while revalidating path:/ (root) clears the whole project.

When content changes, the preview POST route receives a webhook, decides which tags/paths are affected, and calls revalidateTag / revalidatePath. See the official write-up of the tagging mechanism: Configure Next.js App Router Data Cache clearing with Uniform SDK.


The problem: why a publish clears root

On CompositionPublished, the default handler looks up the project map node(s) the composition is mounted on and, for a dynamic node path, walks the segments and stops at the first dynamic (:) segment, revalidating the static prefix before it. Two situations collapse that to a global clear:

  1. Leading dynamic segment. For a node path like /:locale/:category/:product/pageA, the very first segment is dynamic, so the static prefix is empty and normalizes to the root tag (path:/) — wiping everything.

  2. Pattern compositions. If the published composition is a pattern (reused across many routes), it can't be tied to concrete paths, so the handler revalidates the global route tag — again, everything.

Both are conservative-but-correct defaults: the handler can't know which concrete locales/categories exist, nor which compositions embed a pattern.


The idea

Replace "clear the parent / clear everything" with "enumerate what we actually know":

  • Expand dynamic segments whose values you can list:

    • :locale → Uniform Locales API (via LocaleClient)

    • :category → your commerce/catalog source

    • anything you can't enumerate → fall back to clearing the static parent and stop.

  • Resolve pattern consumers via the Relationships API (via RelationshipClient) instead of clearing the global route tag. A "component pattern" is a composition under the hood, so query both compositionPattern and componentPattern.

Net effect for /:locale/:category/:product/pageA:

Configuration

Tags revalidated

Cache kept warm

Default

path:/ (root)

nothing

Locale expander

path:/en, path:/fr, path:/es

every other locale tree

Locale + category expanders

path:/en/shoes, path:/fr/shoes, …

everything except matched category subtrees (only :product unexpanded)

Pattern publish

targeted consumer paths (then expanded)

everything not using the pattern

root/route is reached only if the relevant API can't answer.


Prerequisites

Install the public packages (most are already transitive deps of @uniformdev/canvas-next-rsc):

pnpm add @uniformdev/canvas @uniformdev/project-map @uniformdev/webhooks svix

Set environment variables:

UNIFORM_PROJECT_ID=...
UNIFORM_API_KEY=...            # needs read access to compositions, project map, locales, relationships
UNIFORM_WEBHOOK_SECRET=...     # svix signing secret (recommended) ...
# or, if you use a shared secret in the URL instead:
UNIFORM_PREVIEW_SECRET=...
# UNIFORM_API_HOST=https://uniform.app   # optional; defaults to https://uniform.app

Configure the webhook (Project → Settings → Webhooks) to subscribe to at least CompositionPublished (plus CompositionDeleted, ProjectMapNode*, ManifestPublished, Redirect* — those flow through the stock handler). Skip composition:changed to avoid invalidating on every save.


End-to-end implementation

The handler is split into small modules for clarity. Adjust import aliases (@/lib/...) to your project.

1. Live Uniform clients — lib/preview/uniformClients.ts

All reads happen at webhook time and must be live, so every client gets a no-store fetch.

import { CanvasClient, LocaleClient, RelationshipClient } from '@uniformdev/canvas';
import { ProjectMapClient } from '@uniformdev/project-map';

/** Webhook-time reads must never be served from the Next.js Data Cache. */
const liveFetch: typeof fetch = (input, init) => fetch(input, { ...init, cache: 'no-store' });

const baseOptions = {
  apiKey: process.env.UNIFORM_API_KEY!,
  projectId: process.env.UNIFORM_PROJECT_ID!,
  ...(process.env.UNIFORM_API_HOST ? { apiHost: process.env.UNIFORM_API_HOST } : {}),
  fetch: liveFetch,
};

export const canvasClient = new CanvasClient(baseOptions);
export const projectMapClient = new ProjectMapClient(baseOptions);
export const relationshipClient = new RelationshipClient(baseOptions);
export const localeClient = new LocaleClient(baseOptions);

2. Tag builders — lib/preview/tags.ts

These mirror the SDK's tag format exactly (lowercased, path: / composition: prefix).

/** Builds the `path:/...` cache tag for a path segment. */
export const buildPathTag = (path: string): string =>
  `path:${path.startsWith('/') ? path : `/${path}`}`.toLowerCase();

/** Builds the `composition:<id>` cache tag. */
export const buildCompositionTag = (compositionId: string): string =>
  `composition:${compositionId}`.toLowerCase();

3. Dynamic-segment expanders — lib/preview/expanders.ts

Register one expander per dynamic placeholder you can enumerate. An expander returns the concrete values for a segment (given the prefix resolved so far, so categories can depend on locale), or undefined/[] to signal "can't enumerate → fall back".

import { buildPathTag } from './tags';
import { localeClient } from './uniformClients';

export type RevalidationTargets = { tags: Set<string>; paths: Set<string> };

type Expander = (ctx: { prefix: string }) => Promise<string[] | undefined>;

/** Enumerates project locales, e.g. ['en', 'fr', 'es']. */
const getLocales: Expander = async () => {
  try {
    const { results } = await localeClient.get();
    return results.map((locale) => locale.locale);
  } catch {
    return undefined; // -> fall back for this segment
  }
};

/**
 * Example catalog expander. Replace the body with a call to your commerce
 * backend. `prefix` already contains the resolved ancestors (e.g. `/en`),
 * so you can return locale-specific categories. Return `undefined` to fall back.
 */
// const getCategories: Expander = async ({ prefix }) => {
//   const categories = await fetchCategoriesFor(prefix);
//   return categories ?? undefined;
// };

/** Keyed by the placeholder exactly as it appears in your project map node path. */
export const expanders: Record<string, Expander> = {
  ':locale': getLocales,
  // ':category': getCategories,
};

/**
 * Walks a project map node path, accumulating static segments into `prefix`.
 * At the first dynamic segment it tries that segment's expander:
 *  - values returned -> recurse once per concrete value
 *  - no expander / empty / failure -> revalidate the static parent and stop
 *    (this is exactly the default SDK behavior, scoped to this branch)
 */
export async function expandDynamicPath(
  nodePath: string,
  prefix: string,
  out: RevalidationTargets
): Promise<void> {
  const pieces = nodePath.split('/').filter(Boolean);
  let cursor = prefix;

  for (let i = 0; i < pieces.length; i++) {
    const piece = pieces[i]!;

    if (!piece.startsWith(':')) {
      cursor = `${cursor}/${piece}`;
      continue;
    }

    const expand = expanders[piece];
    const values = expand ? await expand({ prefix: cursor }) : undefined;

    if (!values || values.length === 0) {
      addTarget(cursor || '/', out);
      return;
    }

    const remainder = pieces.slice(i + 1).join('/');
    for (const value of values) {
      const nextPrefix = `${cursor}/${value}`;
      if (remainder) {
        await expandDynamicPath(remainder, nextPrefix, out);
      } else {
        addTarget(nextPrefix, out);
      }
    }
    return; // children handled recursively
  }

  addTarget(cursor || '/', out);
}

function addTarget(path: string, out: RevalidationTargets): void {
  out.tags.add(buildPathTag(path));
  out.paths.add(path);
}

4. Pattern consumers via the Relationships API — lib/preview/relationships.ts

Crucial detail: distinguish "the lookup failed" (return undefined → caller may fall back to route) from "the pattern genuinely has zero consumers" (return [] → clear nothing extra). Only a thrown/HTTP error counts as a failure.

import type { RelationshipResultInstance } from '@uniformdev/canvas';

import { relationshipClient } from './uniformClients';

type PatternRelationshipType = 'compositionPattern' | 'componentPattern';

/**
 * Returns every instance referencing `entityId`, paging through results.
 *  - `[]`        -> success, nothing references it (do NOT clear globally)
 *  - `undefined` -> the lookup failed (caller may fall back to `route`)
 */
async function getConsumingInstances(
  entityId: string,
  type: PatternRelationshipType
): Promise<RelationshipResultInstance[] | undefined> {
  const pageSize = 100;
  const all: RelationshipResultInstance[] = [];
  let offset = 0;

  try {
    for (;;) {
      // `withInstances` requires a single id; response has one element per id.
      const [result] = await relationshipClient.get({
        type,
        ids: entityId,
        withInstances: true,
        limit: pageSize,
        offset,
      });

      all.push(...(result?.instances ?? []));
      offset += pageSize;

      if (!result || offset >= result.totalCount) {
        break;
      }
    }
    return all; // may legitimately be []
  } catch (err) {
    // eslint-disable-next-line no-console
    console.error('[preview] relationships lookup failed', type, err);
    return undefined; // hard failure only
  }
}

/**
 * Resolves the composition IDs that consume a published pattern, checking BOTH
 * pattern types (component patterns are compositions under the hood).
 *  - returns a Set (possibly empty) -> trust it, clear only those
 *  - returns `undefined` -> EVERY lookup failed -> caller falls back to `route`
 */
export async function getConsumingCompositionIds(patternId: string): Promise<Set<string> | undefined> {
  const results = await Promise.all([
    getConsumingInstances(patternId, 'compositionPattern'),
    getConsumingInstances(patternId, 'componentPattern'),
  ]);

  // Fall back ONLY when there is no successful answer at all.
  if (results.every((result) => result === undefined)) {
    return undefined;
  }

  const ids = new Set<string>();
  for (const consumers of results) {
    for (const consumer of consumers ?? []) {
      if (consumer.type === 'composition') {
        ids.add(consumer.instance._id);
      }
    }
  }

  return ids; // empty Set == "not used anywhere" -> nothing extra to clear
}

5 The route — app/api/preview/route.ts

GET/OPTIONS stay stock. POST special-cases CompositionPublished and delegates every other event to the SDK's handler unchanged.

import { ApiClientError, CANVAS_PUBLISHED_STATE } from '@uniformdev/canvas';
import {
  createPreviewGETRouteHandler,
  createPreviewOPTIONSRouteHandler,
  createPreviewPOSTRouteHandler,
} from '@uniformdev/canvas-next-rsc/handler';
import { CompositionPublishedDefinition } from '@uniformdev/webhooks';
import { revalidatePath, revalidateTag } from 'next/cache';
import type { NextRequest } from 'next/server';
import { Webhook } from 'svix';

import { expandDynamicPath, type RevalidationTargets } from '@/lib/preview/expanders';
import { getConsumingCompositionIds } from '@/lib/preview/relationships';
import { buildCompositionTag } from '@/lib/preview/tags';
import { canvasClient, projectMapClient } from '@/lib/preview/uniformClients';

export const GET = createPreviewGETRouteHandler({
  resolveFullPath: ({ path }) => path ?? '/playground',
});

export const OPTIONS = createPreviewOPTIONSRouteHandler();

/** Stock handler reused for every event we don't special-case. */
const defaultPOST = createPreviewPOSTRouteHandler();

export async function POST(request: NextRequest) {
  // Read a clone so the original request/body stays intact for the stock handler.
  const rawBody = await request.clone().text();

  if (!verifyWebhook(request, rawBody)) {
    return new Response('Invalid webhook signature.', { status: 401 });
  }

  const parsed = CompositionPublishedDefinition.schema.safeParse(safeJson(rawBody));

  // Only special-case composition publishes; delegate everything else verbatim.
  if (!parsed.success) {
    return defaultPOST(request);
  }

  const compositionId = parsed.data.id;
  const out: RevalidationTargets = { tags: new Set(), paths: new Set() };

  // 1. Determine which compositions actually need clearing.
  const targetCompositionIds = new Set<string>([compositionId]);
  const composition = await getComposition(compositionId);

  if (composition?.pattern) {
    // Instead of clearing the global `route` tag, ask who actually uses the pattern.
    const consumerIds = await getConsumingCompositionIds(compositionId);

    if (consumerIds === undefined) {
      // Every relationship lookup failed -> safety net only.
      revalidateTag('route');
    } else {
      // Empty set = pattern unused -> nothing extra (no global clear).
      for (const id of consumerIds) {
        targetCompositionIds.add(id);
      }
    }
  }

  // 2. Resolve each target composition's project-map paths and expand them.
  for (const id of targetCompositionIds) {
    const { nodes } = await projectMapClient.getNodes({ compositionId: id });
    for (const node of nodes ?? []) {
      if (node.path) {
        await expandDynamicPath(node.path, '', out);
      }
    }
  }

  // 3. Always clear the published composition's own tag.
  out.tags.add(buildCompositionTag(compositionId));

  // 4. Apply.
  for (const tag of out.tags) {
    revalidateTag(tag);
  }
  for (const path of out.paths) {
    revalidatePath(path);
  }

  return Response.json({
    handled: true,
    compositionId,
    tags: [...out.tags],
    paths: [...out.paths],
  });
}

/** Fetches the published composition, treating 404 as "not found". */
async function getComposition(compositionId: string) {
  try {
    return await canvasClient.getCompositionById({
      compositionId,
      state: CANVAS_PUBLISHED_STATE,
    });
  } catch (err) {
    if (err instanceof ApiClientError && err.statusCode === 404) {
      return null;
    }
    throw err;
  }
}

/** Verifies the svix signature, or a shared `?secret=` fallback. */
function verifyWebhook(request: NextRequest, rawBody: string): boolean {
  const webhookSecret = process.env.UNIFORM_WEBHOOK_SECRET;

  if (webhookSecret) {
    try {
      const headers = Object.fromEntries(request.headers.entries());
      new Webhook(webhookSecret).verify(rawBody, headers);
      return true;
    } catch {
      return false;
    }
  }

  const previewSecret = process.env.UNIFORM_PREVIEW_SECRET;
  if (previewSecret) {
    return request.nextUrl.searchParams.get('secret') === previewSecret;
  }

  // Neither configured -> refuse rather than blindly trust.
  return false;
}

function safeJson(raw: string): unknown {
  try {
    return JSON.parse(raw);
  } catch {
    return undefined;
  }
}

Worked example

Project map node: /:locale/:category/:product/pageA, with the locale and category expanders registered.

Publishing a composition mounted on that node now produces:

composition:<id>
path:/en/shoes      path:/fr/shoes      path:/es/shoes
path:/en/shirts     path:/fr/shirts     path:/es/shirts
...

Because every page fetch is tagged with all its ancestors, revalidating path:/en/shoes clears /en/shoes, /en/shoes/sneaker-1, /en/shoes/sneaker-2, … while /fr/... and /en/shirts/... stay cached. Only the unexpanded :product level remains coarse.

If the published composition is a pattern, the relationships lookup returns the concrete consuming compositions; each is resolved to its node path and expanded the same way — no global route clear.


Decision matrix (when root/route is still cleared)

Situation

Action

Dynamic segment with an expander

expand into concrete per-value tags

Dynamic segment, no/failed expander

clear the static parent and stop (can be root for a leading dynamic)

Pattern publish, ≥1 consumer found

clear consumers' concrete paths (then expand)

Pattern publish, 0 consumers (success)

clear nothing beyond the composition's own tag

Pattern publish, both lookups failed

fall back to global route

root/route is now a genuine safety net, not the normal path.


Tuning & caveats

  • Enumeration cost. Locales × categories multiplies revalidateTag calls. For very large catalogs, expand only the high-value upper segments (locale, maybe top categories) and let deeper segments fall back. Cache locale/category lookups (they change rarely).

  • Relationships pagination. withInstances is single-id and capped at limit=100; a widely-reused pattern can mean many pages plus a getNodes call per consumer. For extremely hot patterns, a single targeted parent clear may be cheaper than expanding thousands of consumers.

  • Permissions. The API key must be able to read the referenced entities; inaccessible instances are returned masked, so guard against placeholder IDs before resolving paths.

  • Placeholder naming. Expander keys must match the dynamic segment text in your project map node paths (e.g. :locale). Verify against your actual nodes.

  • Patterns still need both types. Always query compositionPattern and componentPattern; component patterns are compositions under the hood and may be the published entity.

  • Security. Keep UNIFORM_WEBHOOK_SECRET (or UNIFORM_PREVIEW_SECRET) set — the handler refuses unsigned requests by design.