ChaiBuilder Logo

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

  1. No build-time page generation. Deploys are fast because pages are not pre-rendered at build time.
  2. 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.
  3. 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 from page-<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 fetch and fetchItem

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:

  1. $cacheTags is always stripped. It never reaches the block, never lands in pageData, and is never bindable with {{ }}. Use any key name you like for real data.
  2. 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.
  3. 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.
  4. 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 $cacheTags never 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.

© ChaiBuilder. All rights reserved.