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-rscPackages used (all public):
@uniformdev/canvas-next-rsc,@uniformdev/canvas,@uniformdev/project-map,@uniformdev/webhooks,svixYou 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:
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 theroottag (path:/) — wiping everything.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
routetag — 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 (viaLocaleClient):category→ your commerce/catalog sourceanything 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 globalroutetag. A "component pattern" is a composition under the hood, so query bothcompositionPatternandcomponentPattern.
Net effect for /:locale/:category/:product/pageA:
Configuration | Tags revalidated | Cache kept warm |
|---|---|---|
Default |
| nothing |
Locale expander |
| every other locale tree |
Locale + category expanders |
| everything except matched category subtrees (only |
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 |
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 |
root/route is now a genuine safety net, not the normal path.
Tuning & caveats
Enumeration cost. Locales × categories multiplies
revalidateTagcalls. 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.
withInstancesis single-id and capped atlimit=100; a widely-reused pattern can mean many pages plus agetNodescall 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
compositionPatternandcomponentPattern; component patterns are compositions under the hood and may be the published entity.Security. Keep
UNIFORM_WEBHOOK_SECRET(orUNIFORM_PREVIEW_SECRET) set — the handler refuses unsigned requests by design.