Caching & Revalidation
ChaiBuilder is optimized for content-heavy, public-facing sites: pages are static once built, and edits go live by invalidating exactly what changed. This page explains the cache layers, the tags a render collects, and how to invalidate pages from your own code.
The rendering model in one pass
- No build-time page generation. Deploys are fast because pages are not pre-rendered at build time.
- Built on first visit. The first request to a page renders it server-side and stores the result in the Next.js cache. Subsequent visitors get the cached page.
- Invalidated by tag. Publishing in the builder, or firing a tag from your own code, invalidates the cache entries that carry that tag. The next visitor triggers a rebuild. No redeploy, no full rebuild.
This is standard Next.js SSG + ISR (the public catch-all route is force-static), so
every host that supports Next.js caching supports it - Vercel natively, Node servers via
the on-disk cache, Cloudflare via the OpenNext incremental cache.
Tags are collected during the render
The important mechanic: a page's cache entry is tagged with everything that went into producing it. As the render resolves the page, its global blocks, and its data, each of those steps attaches its own tags to the route being rendered. Firing any one of those tags later regenerates every page that registered it.
You never assemble that list yourself. A render collects, among others:
| Tag | Registered by | Fired when |
|---|---|---|
page-<pageId> |
the page itself, and every global block merged into it | that page or global block is published, taken offline, or deleted |
breadcrumb-<pageId> |
pages in the breadcrumb trail | the ancestor page's routing changes |
page-slug:<pageId> |
pages that link to another page by id | that page's online slug changes, or it is taken offline or deleted |
site-global-data, site-global-data-<appId> |
the global data provider | site global data is republished |
page-type-data-<appId>-<pageType> |
the page-type data provider | a record of that page type is published |
repeater-data-<appId>-<sourceId> |
every repeater or collection item block reading that source | the source's underlying data changes |
website-settings-<appId> |
the site settings and theme | settings or theme are published |
whatever your provider returns in $cacheTags |
your own data providers | you fire them |
Two of these are worth calling out because they changed how invalidation behaves:
- Global blocks tag their consumers. A page that merges a shared header, footer, or any
other global block registers
page-<globalBlockId>, so publishing that global block regenerates every page using it. See Global Blocks. - Link resolution has its own tag.
page-slug:<pageId>is separate frompage-<pageId>and is fired only when a page's published slug changes, or when the page is taken offline or deleted. Editing a slug in draft does not rebuild every page that links to it; publishing the new slug does.
Note the unsuffixed tags in that table (site-global-data, page-type-data, website-settings).
They exist so the platform can invalidate broadly, and on a multi-tenant deployment they cross
site boundaries. Fire the -<appId> variant unless you genuinely mean every site.
Tagging your own data with $cacheTags
Any data provider can return a $cacheTags array alongside its data. Those tags are
registered on the page being rendered, so when your source data changes you can fire the
tag and every page that used the data regenerates.
The convention is the same for all three provider surfaces:
- a page type's
dataProvider - a block's
dataProvider - a repeater data source's
fetchandfetchItem
The global data provider is the exception - it is persistently cached with its own tags, so it
does not participate in $cacheTags. A $cacheTags key returned from it is not stripped and will
show up in your {{ global.* }} scope.
// A page-type data provider for products.
dataProvider: async ({ pageProps }) => {
const product = await findProduct(pageProps.pageIdentifier);
return {
product,
// Collection-level tag plus a per-document tag, both scoped to the site.
$cacheTags: [
`products-${siteId}`,
...(product ? [`products-${siteId}-${product.id}`] : []),
],
};
};
When the product changes, your sync job or webhook fires products-<siteId>-<productId>
and only the pages that rendered that product rebuild. A bulk import fires
products-<siteId> instead and rebuilds all of them.
Four rules to hold on to:
$cacheTagsis always stripped. It never reaches the block, never lands inpageData, and is never bindable with{{ }}. Use any key name you like for real data.- Tags are registered on live renders only. In the builder and in draft mode the key is stripped and nothing is registered, because those paths are not persistently cached.
- Scope tags to the site. Include the app or site id in every tag. An unscoped tag invalidates other tenants' pages on a shared deployment.
- Emit a source-level tag even on a miss or a failure. A page that rendered before the record existed, or that cached an empty result after an error, has no per-document tag to fire later. A collection-level tag is the only thing that can heal it.
The platform follows the same rule internally: a repeater block registers its source tag before the fetch runs, so a page that cached an empty error result is still invalidatable.
What is cached, and for how long
| Layer | What uses it | Lifetime |
|---|---|---|
| Request memoization | repeater and collection fetches, page-type data, anything needed twice in one render | one render |
| Persistent, tagged | page and global-block content, site settings, global data | until a tag is fired |
| Rendered page (ISR) | the HTML itself, tagged with everything above | until one of its tags is fired |
Page-type data is request-cached, not persistently cached. The provider runs on every
regeneration of the route. That is deliberate: it is what lets the provider return per-record
$cacheTags, so publishing one record invalidates exactly the pages that rendered it rather
than every page of that type. Keep page-type providers fast - they are on the critical path
of every cache miss.
Block data providers are not cached at all; they are awaited per block, per render.
Draft mode is never cached
Preview uses Next.js draft mode: while previewing, data is fetched fresh per request (a request-scoped memory cache prevents duplicate queries inside one render, but nothing persists). Cache tags are inert in draft mode - nothing is registered, because there is no persistent entry to invalidate. Editors always preview live data; the persistent cache and CDN are only in the published-visitor path.
Manual revalidation
For content that changes outside the builder - an external system writing to your database, a search index sync, a scheduled import - your app exposes a revalidation endpoint that accepts tags and paths and revalidates each.
curl -X POST https://www.example.com/api/revalidate \
-H "Content-Type: application/json" \
-H "x-webhook-secret: $CHAIBUILDER_WEBHOOK_SECRET" \
-d '{"tags": ["products-site_123"], "paths": ["/pricing"]}'
Both tags and paths accept an array or a comma-separated string. Protect the route with
a secret, generate it like any other (openssl rand -hex 32), and store it wherever the
calling system keeps its credentials. Send at least one of tags or paths; an empty body
revalidates nothing useful and returns an error.
This is the other half of $cacheTags: your provider declares the tag at render time, and
whatever mutates the data fires it here. If your mutation runs inside your own server code
you can call revalidateTag directly instead of going through HTTP.
Putting a CDN in front
The ISR cache already makes origin responses cheap, and platform CDNs (Vercel, Cloudflare)
integrate with revalidation automatically. If you add an external CDN in front of a
Node deployment, be careful with HTML caching: the CDN does not know when you publish. Safe
default - let the CDN cache immutable assets (/_next/static, media URLs) and pass HTML
through to the origin, which serves it from the ISR cache anyway.
Troubleshooting
- Published change not visible - check you published (not just saved), then hard-refresh
to rule out browser cache. On Node, confirm the process can write to
.next(the ISR cache lives there). - Publishing a global block did not update the pages using it - confirm the pages were rendered after the global block was added to them. A page cached before it referenced the global block never registered that tag.
- Custom block or page-type data is stale - the source changed without firing a matching
tag. Check the provider actually returns
$cacheTags, and that the fired tag string matches exactly, including the site id. - A page cached an empty result and never recovers - the provider returned no per-document tag on the miss. Add a collection-level tag that is emitted unconditionally.
- A block's tags are never registered - a block hidden by a
condition is dropped before its provider result
is consumed, so its
$cacheTagsnever reach the page. Data that must keep a page invalidatable belongs in a page-type provider, not in a conditionally hidden block. - Firing a tag rebuilt far more pages than expected - the tag is too coarse. Add a per-document tag alongside the collection-level one and fire the narrow one on single-record updates.
- Stale content behind an external CDN - your CDN is caching HTML; exclude HTML or purge on publish.
- Everything rebuilds after deploy - expected; a new deployment starts with an empty ISR cache and pages rebuild on first visit.
Related
- Rendering Model - the conceptual view.
- Data Providers - what runs during a render, and what is cached.
- Global Blocks - what
page-<globalBlockId>invalidates. - Hosting Overview - cache support per target.
- Preview & Publish - the editor-side workflow.

