Performance & Optimization
How Cimplify storefronts cache and render fast, and the page-level knobs you control to keep them that way.
Cimplify storefronts are fast by default. Cimplify runs a multi-layer cache in front of every storefront so most requests never touch a render at all. This page covers the small set of things you control in your page code that keep that cache working: the rendering model, the canonical page shape, TTLs, static asset caching, and the anti-patterns that quietly defeat caching.
Use the ISR Previous Model, not Cache Components
Cimplify templates use Next 16's Previous Model:
export const revalidate = Nat the page levelcacheOptions: { revalidate, tags }on SDK reads (forwarded asfetch().next.{revalidate,tags})revalidateTagfor invalidationgenerateStaticParamson every[slug]route
Keep cacheComponents off in next.config.ts. 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.
Recommended page shape
import { notFound } from "next/navigation";
import { getServerClient, tags } from "@cimplify/sdk/server";
// generateStaticParams: enumerate known slugs at build so the page is
// edge-cacheable. Placeholder fallback keeps the build alive when the
// catalogue API isn't reachable at build time.
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 const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const r = await getServerClient().catalogue.getProductBySlug(slug, {
cacheOptions: { revalidate: 3600, tags: [tags.product(slug), tags.products()] },
});
// Distinguish a real 404 from a transient SDK error. Calling notFound()
// on a transient error caches the not-found page for an hour, visitor
// sees "This page couldn't load" until something else invalidates.
if (!r.ok) {
if (r.error.code === "NOT_FOUND") notFound();
// Soft state: caller renders a skeleton; the next request retries.
return <ProductDetailSkeleton />;
}
return <ProductDetail product={r.value} />;
}Every storefront page should look like this. Five non-negotiable elements:
| Element | Why |
|---|---|
generateStaticParams returning real slugs | Without it Next emits Cache-Control: no-store on [slug] routes and the response can't be cached |
Placeholder fallback in generateStaticParams | If the catalogue API is unreachable at build time, an empty array can fail the build. The placeholder keeps it alive |
export const revalidate = 3600 (or your TTL) | Without it Next treats the page as fully dynamic |
cacheOptions: { revalidate, tags } on SDK reads | Tags are what revalidateTag and Cimplify's automatic invalidation target |
| Soft-render on transient SDK error | Calling notFound() on every !r.ok makes a brief upstream timeout look like a 404 for the entire revalidate window |
TTL recommendations
| Page type | revalidate value | Why |
|---|---|---|
| Home, shop, search, sitemap, llms.txt | 3600 (1h) | Browsed often, content changes ~daily |
PDP (/products/[slug]) | 3600 | Same; automatic invalidation handles real-time edits |
| Category, collection list pages | 3600 | Same |
| Cart, checkout, account | (no revalidate) | Per-user, never cache. A Cookie header bypasses the edge cache anyway |
/api/v1/* (proxied) | (no revalidate) | The API handles its own caching |
You can set revalidate as high as you like. Cimplify's automatic invalidation flips a cached page fresh within ~1–3 seconds of a merchant edit regardless of the TTL, so a 1-hour revalidate is not a 1-hour staleness window. The TTL is just the upper bound for content that isn't edit-driven.
Verifying the cache is working
Every storefront response carries an X-Cimplify-Edge-Cache: HIT | MISS | BYPASS header so you can confirm caching without guessing:
$ for i in 1 2 3; do
curl -s -o /dev/null -w "ttfb %{time_starttransfer}s | " "$URL"
curl -sI "$URL" | grep -i 'x-cimplify-edge-cache'
done
ttfb 1.77s | X-Cimplify-Edge-Cache: MISS ← cold render, populates cache
ttfb 0.05s | X-Cimplify-Edge-Cache: HIT ← warm
ttfb 0.05s | X-Cimplify-Edge-Cache: HITBYPASSon a page that should be cached → the response is missingCache-Control: s-maxage(usually a missingrevalidate), or the request carried aCookie/Authorizationheader.MISSrepeatedly on warm requests → the cache warms independently per region, so testing from rotating locations always shows a first MISS. Also check the page isn't emittingCache-Control: max-age=0orprivate.
Static asset caching
Everything above tunes the page (HTML) cache. Static assets (JS/CSS bundles, fonts, files in public/, uploaded brand assets) sit on a separate HTTP layer. Most of it is automatic, but a few habits quietly defeat it. The rule of thumb: content-hashed URLs are cached forever; stable URLs are revalidated.
| Asset | How it's served | What you do |
|---|---|---|
/_next/static/** (JS/CSS chunks) | Content-hashed by next build, served Cache-Control: public, max-age=31536000, immutable. A new deploy mints new hashes, so there's no staleness risk | Nothing; automatic |
Fonts via next/font | Self-hosted, hashed into /_next/static/media, same immutable caching | Prefer over linking a font CDN: no extra origin, no handshake on a render-critical resource |
Files in public/ | Served from the storefront origin but not hashed, so the URL is stable across edits. Served Cache-Control: public, max-age=0, must-revalidate (edge-cached, but the browser revalidates) | Don't expect immutable caching here. For forever-cacheable assets, import through the bundler (gets a hashed /_next/static URL) or upload via cimplify assets |
Uploaded brand assets via assetUrl() | Long-lived CDN cache on storefrontassetscdn.cimplify.io, with on-the-fly transforms. Immutable per storage key | Re-uploading to the same key needs a new path (or a changed transform query) to bust |
| Catalogue images (Cloudinary) | Long-lived immutable on the image CDN | Size them at the source; see Responsive images |
Don't cache-bust hashed assets
// ✗ wrong: the hash already versions the file; a changing query string
// forces a refetch on every load and poisons both browser and edge cache
<script src={`/_next/static/chunks/main.js?v=${Date.now()}`} />
<link rel="stylesheet" href={`${asset}?t=${buildId}`} />Manual cache-busting on a content-hashed URL is always wrong. The filename hash is the version. Reference the asset as-is and let the immutable cache do its job. A new deploy changes the hash, so users get the new bytes the moment they land on the new HTML.
Don't import large media into the JS bundle
import hero from "./hero.png" for a multi-MB image gets a hashed URL and caches fine, but it bloats the build graph and ships the original at full resolution. Upload it via cimplify assets and reference it with assetUrl("hero/main.jpg", { w: 1600 }) so it streams from the CDN at the size you ask for.
Verifying asset caching
URL="https://<your-handle>.mycimplify.com"
# A hashed chunk should be immutable
asset=$(curl -s "$URL/" | grep -oE '/_next/static/[^"]+\.(js|css)' | head -1)
curl -sI "$URL$asset" | grep -i 'cache-control'
# → cache-control: public, max-age=31536000, immutableIf a hashed /_next/static asset comes back max-age=0 or no-store, something is rewriting headers in front of the storefront (a custom proxy, a headers() override in next.config.ts). Remove it; the platform sets the right header.
Client-side prefetching
Everything above tunes server rendering and caching. The React SDK also prefetches in the browser so the next interaction is usually a cache hit:
| What | When |
|---|---|
| Product data + primary image | A product card comes within 300px of the viewport, or on hover/focus |
| Next pagination page | After the current CataloguePage page renders |
| Neighboring gallery images | While a product gallery is on screen |
preconnect to the API and asset CDN | On provider mount; emitted in the SSR stream on React 19 |
| First grid row images | loading="eager" + fetchPriority="high"; everything below the fold is lazy |
Speculative work is skipped for visitors with data-saver enabled or on 2g connections, capped to a few concurrent requests, deduplicated against the hook cache, and excluded from product-view analytics.
You control it from the provider: prefetch={{ mode }} with "aggressive" (default), "hover", or "off", plus rootMargin and images. Per-card override: <ProductCard prefetch="off" />. Pass linkComponent (e.g. next/link) to add route-level prefetch to SDK-rendered links. See React SDK: Prefetching.
SDK timeout
The SDK's default per-call timeout is 5 seconds, short on purpose, so one slow upstream call can't stall a whole page render. When a call exceeds it, Result.ok becomes false, the page renders with empty data (soft-render), and that result is cached so subsequent hits are instant.
Override only for genuinely-large bulk reads, never on a storefront request path:
import { createCimplifyClient } from "@cimplify/sdk";
// Admin tools, batch exports, etc. Never for storefront request paths.
const adminClient = createCimplifyClient({
secretKey: process.env.CIMPLIFY_SECRET_KEY,
timeout: 30_000,
});Common anti-patterns
Calling notFound() on transient SDK errors
// ✗ wrong: caches the not-found page for an hour on any upstream blip
const r = await getServerClient().catalogue.getProduct(slug, {...});
if (!r.ok) notFound();// ✓ right: distinguish 404 from transient
if (!r.ok) {
if (r.error.code === "NOT_FOUND") notFound();
return <Skeleton />;
}Skipping generateStaticParams on [slug] routes
Without it Next emits Cache-Control: no-store on the route, defeating the cache entirely. next build will show the route as ƒ Dynamic instead of ● SSG.
Enabling cacheComponents: true "just to try it"
It's a global flag (you can't enable it for one page) and storefronts 5xx under it. The 'use cache' directive only works with the flag on. Don't.
Importing @cimplify/sdk/server/evict from a page
That subpath is for Cimplify's deploy pipeline only and has build-time dependencies a storefront doesn't install. From your page code, only ever import from @cimplify/sdk/server.
Adding Cookie to the cache key
Don't try to build cart-aware page caching. Any request with a Cookie header bypasses the cache by design. Cart contents are per-user and can never be shared, and a stale cart in someone else's cache is a data-leak class of bug. Pull the cart into a client island and hydrate from the SDK on the browser.
What to do when something breaks
- TTFB suddenly slow on every page → check the
X-Cimplify-Edge-Cacheheader is present and readsHITon warm requests. PersistentMISS/BYPASSmeans the page lost itss-maxage(checkrevalidateis still declared). HITbut stale data → the page'scacheOptions.tagsdon't match the resource being edited. See Revalidation: tags must be keyed by database ID, not slug.Cache-Control: no-storeon a[slug]page →generateStaticParamsnot declared, or returning empty without the placeholder fallback.- React #419 on category/collection navigation → the
notFound()-on-transient race. See Revalidation for the soft-render pattern.
Where next
- Revalidation: Tag invalidation contract
- Server SDK: Canonical page shapes,
getServerClient, tag builders - CLI doctor: Pre-deploy health check
Revalidation
Cache tags + revalidation helpers used to invalidate storefront Next.js ISR caches when the underlying data changes.
Drop-in checkout (hosted Pay)
The fastest path to a paid order: create a checkout session on your server, then redirect the customer to the URL it returns. Cimplify hosts the entire checkout UI at `pay.cimplify.io/s/<sessionId>` (auth, address, payment method, compliance). You get a webhook (or success-URL redirect) when payment lands.