Revalidation
Cache tags + revalidation helpers used to invalidate storefront Next.js ISR caches when the underlying data changes.
Cimplify storefronts cache catalogue reads with Next 16's ISR (export const revalidate = N + next.tags via the SDK's cacheOptions). When a merchant edits a product, collection, or brand asset, Cimplify invalidates the matching caches for your storefront automatically, with no /api/revalidate webhook round-trip required.
This page is the canonical contract between Cimplify and your storefront. Use the typed helpers (never hand-write tag strings) so the scheme stays in one place.
Why ISR, not Cache Components
Cimplify templates use Next 16's Previous Model: export const revalidate at the page level, cacheOptions: { revalidate, tags } on SDK reads (forwarded as fetch().next.{revalidate,tags}), and revalidateTag for invalidation. The 'use cache' directive (Cache Components) is still experimental and its runtime constraints don't suit hosted storefronts. The Previous Model is stable, fully supported, and what every Cimplify template ships with. Keep cacheComponents off in next.config.ts.
Tags
All tags are namespaced under cimplify: so they can't collide with consumer tags. Build them via tags from @cimplify/sdk/server:
import { tags } from "@cimplify/sdk/server";
export const revalidate = 3600;
async function getProducts() {
const r = await getServerClient().catalogue.getProducts(
{ limit: 24 },
{ cacheOptions: { revalidate: 3600, tags: [tags.products()] } },
);
return r.ok ? r.value.items : [];
}Catalogue
| Helper | Tag |
|---|---|
tags.products() | cimplify:products |
tags.product(id) | cimplify:product:{id} |
tags.categories() | cimplify:categories |
tags.category(id) | cimplify:category:{id} |
tags.categoryProducts(id) | cimplify:category:{id}:products |
tags.collections() | cimplify:collections |
tags.collection(id) | cimplify:collection:{id} |
tags.collectionProducts(id) | cimplify:collection:{id}:products |
tags.tag(name) | cimplify:tag:{name} (product tag, e.g. "vegan") |
tags.addons() | cimplify:addons |
tags.addon(id) | cimplify:addon:{id} |
tags.stock() | cimplify:stock |
tags.stockFor(productId) | cimplify:stock:{productId} |
Brand / business
| Helper | Tag |
|---|---|
tags.business() | cimplify:business |
tags.brand() | cimplify:brand |
tags.locations() | cimplify:locations |
tags.location(id) | cimplify:location:{id} |
tags.locale() | cimplify:locale |
Pricing / subscriptions
| Helper | Tag |
|---|---|
tags.pricing() | cimplify:pricing |
tags.subscriptions() | cimplify:subscriptions |
tags.subscription(id) | cimplify:subscription:{id} |
Customer-scoped (Server Actions only)
| Helper | Tag |
|---|---|
tags.orders(customerId) | cimplify:orders:{customerId} |
tags.order(id) | cimplify:order:{id} |
Granularity
Tag with both a broad and a precise tag on every read so either-flavour invalidation works:
cacheOptions: { revalidate: 3600, tags: [tags.products(), tags.product(id)] }The broad tag (products) catches "I changed something product-related, invalidate everything." The precise tag (product:{id}) catches "I changed exactly this one product, invalidate only its entries."
Revalidating from a Server Action
import { revalidateProduct, revalidateCollection } from "@cimplify/sdk/server";
export async function saveProduct(id: string, data: ProductInput) {
await client.catalogue.updateProduct(id, data);
await revalidateProduct(id);
}Helpers:
| Helper | Invalidates |
|---|---|
revalidateProducts() | products |
revalidateProduct(id) | product:{id} + products |
revalidateCategories() | categories |
revalidateCategory(id) | category:{id} + category:{id}:products + categories |
revalidateCollections() | collections |
revalidateCollection(id) | collection:{id} + collection:{id}:products + collections |
revalidateBusiness() | business |
revalidateBrand() | brand |
revalidateLocations() | locations |
revalidateLocation(id) | location:{id} + locations |
revalidatePricing() | pricing + products |
revalidateAddOns() | addons |
revalidateAddOn(id) | addon:{id} + addons |
revalidateSubscriptions() | subscriptions |
revalidateSubscription(id) | subscription:{id} + subscriptions |
revalidateStock(productId?) | stock:{productId} + stock (or just stock if no id) |
revalidateByTag(tag) | escape hatch for a raw tag |
The Cimplify → storefront eviction contract
When a merchant edits something in the dashboard, Cimplify automatically invalidates the matching caches for every storefront serving that business, globally, in ~1–3 seconds, with no webhook round-trip into your storefront. You don't wire anything up; tagging your reads correctly is the whole contract.
The tags Cimplify dispatches use the exact strings from the tables above, keyed by database ID. If you've tagged your cacheOptions.tags with the helpers above, your storefront stays in sync automatically.
Dynamic routes: tag by ID, never by slug
Cimplify dispatches revalidation tags keyed by database ID, not by URL slug. A /products/[slug] page that caches itself under tags.product(slug) will never be invalidated by an edit; the tag the storefront wrote doesn't match the tag Cimplify fires.
Resolve the slug to a record first, then tag with record.id:
import { notFound } from "next/navigation";
import { getServerClient, tags } from "@cimplify/sdk/server";
export const revalidate = 3600;
export async function generateStaticParams() {
const r = await getServerClient().catalogue.getProducts({ limit: 10_000 });
if (!r.ok || r.value.items.length === 0) return [{ slug: "__placeholder__" }];
return r.value.items.map((p) => ({ slug: p.slug ?? p.id }));
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// First resolve slug → product. Tag the resolution read with the
// collection-level tag so adds/removes invalidate it. This call hits the
// SDK with a fallback-broad tag.
const r = await getServerClient().catalogue.getProductBySlug(
slug,
{ cacheOptions: { revalidate: 3600, tags: [tags.products()] } },
);
if (!r.ok) notFound();
// Now re-fetch with the resolved ID so the per-product tag is keyed on the
// stable identifier; revalidateProduct(id) from Cimplify will hit this
// entry on the next mutation.
const detailed = await getServerClient().catalogue.getProduct(
r.value.id,
{ cacheOptions: { revalidate: 3600, tags: [tags.product(r.value.id), tags.products()] } },
);
if (!detailed.ok) notFound();
return <ProductDetail product={detailed.value} />;
}The same pattern applies to [slug] routes for categories, collections, addons, locations, and so on: always tag with the resolved record.id, never with the URL slug.
Why this matters
- Renames don't orphan. The cache key includes the slug (path-based), so a renamed product gets a fresh cache entry under the new slug. The old slug-keyed entry is invalidated via the
tags.products()collection tag and will 404 naturally. - IDs are stable. Cimplify's bus emits
ProductUpdated { id }events; the slug isn't on the event payload, and adding it would create races with concurrent rename. Keeping the tag scheme ID-only keeps the contract simple. - You get instant freshness. With Cimplify's automatic eviction, a 1-hour
revalidateentry can sit cached and still flip fresh within ~3s of an edit, but only if the tag actually matches.
SDK timeout
The SDK's default per-call timeout is 5 seconds. Picked so a single slow upstream call can't exhaust the page render budget; a hung getCollections() would otherwise take the entire page render with it. With the 5s default, a slow call fails fast, Result.ok becomes false, the page renders with empty data, and the result is cached so subsequent hits are instant.
Override only for genuinely-large bulk reads (admin tooling, batch jobs):
import { createCimplifyClient } from "@cimplify/sdk";
const adminClient = createCimplifyClient({
secretKey: process.env.CIMPLIFY_SECRET_KEY,
timeout: 30_000, // 30s for a 10k-row export
});Don't cache empty responses as if they were real
If a render produces empty results because of a transient backend hiccup, that empty render gets cached for the full revalidate window, and every visitor sees an empty storefront until someone manually invalidates.
Two defences:
1. The SDK's 5s timeout above stops a slow call from hanging the whole render, but the result is still Result.ok = false. Branch on that and short-circuit to a not-found / soft state rather than committing the empty render to cache:
async function getCategory(slug: string) {
const r = await getServerClient().catalogue.getCategoryBySlug(
slug,
{ cacheOptions: { revalidate: 3600, tags: [tags.categories()] } },
);
// Distinguish a real 404 from a transient SDK error. Only `notFound()` on
// the real 404; for transient errors, render a soft loading state so the
// cache doesn't lock in an incorrect "page couldn't load" for an hour.
if (!r.ok) {
if (r.error.code === "NOT_FOUND") notFound();
return { soft: true as const };
}
return { soft: false as const, category: r.value };
}2. Use the eviction-pipeline path for catalogue invalidation rather than relying on revalidate window expiry. A real edit in the dashboard purges the cache within seconds; the revalidate value is a backstop, not the primary refresh mechanism.
Tag taxonomy is locked
The vocabulary above is the v1 contract. Adding new tag helpers is non-breaking; removing or renaming any of them bumps the SDK major version. Agents that bake these strings into prompts can pin to v0.x of @cimplify/sdk.
Server Components
`@cimplify/sdk/server` is a Node-only entry for the Next.js App Router. It ships three things: a request-memoized client factory, a typed cache-tag scheme, and Server Action revalidation helpers.
Performance & Optimization
How Cimplify storefronts cache and render fast, and the page-level knobs you control to keep them that way.