The Visual Website Builder
You Actually Own. React + Next.js, low-code, self-hosted. Your data, your infrastructure, every feature included.
© ChaiBuilder. All rights reserved.

Data binding is what turns one page into many. Instead of typing a blog post's title into a
heading block, you bind the heading to {{blog.title}} and the same page renders correctly
for every post in the collection.
Anywhere a block takes a text value, you can write {{ }} and pull in data instead:
{{blog.title}}
{{global.companyName}}
{{blog.author.name}}
Bindings work in block text, link URLs, image sources, SEO fields, and structured data. They resolve on the server before the page is rendered, so a bound page is still a static, cacheable page - see Caching and revalidation.
Four sources, all wired in code by a developer, all available to editors once wired.
| Source | Appears as | Use it for |
|---|---|---|
| Page type provider | Top-level keys, e.g. blog.* |
The record this page is about |
| Global provider | global.* |
Site-wide values: company name, social links, contact details |
| Collections | Repeater sources | Lists: latest posts, product grids, team members |
| Block data provider | Props on one block type | Data only one custom block needs |
A page type connects a URL pattern to a content source. A page of type blog at
/blog/hello-world loads that post and exposes it as blog. A global provider loads
once per site and is available on every page under global. A collection is a list a
Repeater block can iterate over.
All four are declared in chaibuilder.config.ts:
import { asChaiBuilderGlobalProvider, buildChaiBuilderConfig } from 'chaipro/payload'
import { Blog } from '@/collections/Blog'
import { Legal } from '@/collections/Legal'
export default buildChaiBuilderConfig({
// ...
// Site-wide data → available as {{global.*}}
globalDataProvider: asChaiBuilderGlobalProvider({ slug: 'site-config' }),
// Dynamic pages backed by a collection
pageTypes: [
{
collection: Blog,
helpText: 'A blog post page',
dynamicSegments: '/[a-zA-Z0-9-]+',
dataProviderDepth: 2,
},
],
// Collections a Repeater can list
collections: [Blog, Legal],
// Per-block data, keyed by block type
blockDataProviders: {
DocsSidebar: getDocsSidebarDataProvider,
},
})
A Payload collection adapted this way is exposed under its singular camel-cased name, so
a collection with slug blog becomes {{blog.title}} and one with slug case-studies
becomes {{caseStudy.title}}. dataProviderDepth controls how deep related documents are
populated - depth 2 is what makes {{blog.author.name}} resolve rather than returning an id.
A page type does not have to come from the CMS. Any async function that returns an object works, which is how a docs site backed by a markdown repository exposes the same binding surface as a Payload collection.
Providers run on the server, per request, and receive lang, draft, inBuilder, and the
page props. See Configuration for the full signatures.
There are three ways to author one, and they produce the same thing.
Type it. In any text field in the settings panel, type {{ and an autocomplete opens
with the data available on this page. Objects drill down - pick blog, then author, then
name. Once inserted, the binding collapses into a compact badge rather than raw text, so
Welcome to {{blog.title}} reads as prose with one chip in it. A badge turns red when the
expression is not valid.
Pick it. Next to each bindable field label is a small { } button that opens a
searchable tree of everything in scope. Useful when you know the value exists but not what
it is called.
Toggle the preview. The lightning-bolt button in the canvas top bar switches binding
resolution on and off. On, the canvas shows real data - the actual post title. Off, it shows
the raw {{blog.title}}, which is how you check what a block is actually bound to. The
button only appears when the page has data.
Only text fields are bindable. Numbers, toggles, and dropdown fields use their normal
controls, and a {{ }} typed into one stays literal text.
A binding is usually a plain path, and a plain path is the safest thing to write - it walks the data without evaluating anything, and a missing step yields an empty string rather than an error.
Expressions are also supported for the cases a path cannot cover:
{{blog.title.toUpperCase()}}
{{blog.readingTime + ' min read'}}
{{blog.price * 1.2}}
{{blog.subtitle ?? 'Untitled'}}
{{blog.commentCount > 0 ? 'Join the discussion' : 'Be the first to comment'}}
{{Math.round(blog.score)}}
Expressions are validated before they run, and the allowed surface is deliberately narrow.
Available: arithmetic, comparison, ternaries, ??, string and array methods, and the
Math, Number, String, Boolean, JSON, parseInt, parseFloat, and isNaN
globals. Not available: assignment, function declarations, template literals, new,
this, loops, fetch, window, process, or anything reaching into constructor or
__proto__. Expressions are capped at 500 characters.
Bracket indexing is not supported. Write {{blog.tags.0.name}}, not
{{blog.tags[0].name}} - the latter is rejected and renders empty.
Bound values are HTML-escaped, so a title containing & or < is safe. Rich-text and
icon props are the exception: they render markup, and go through an HTML sanitizer first.
A Repeater renders its children once per item in a list. It is how you build a post grid, a team page, or a feature list driven by content.
Blogs, Legal) or an
array already on the page, such as {{blog.tags}}.{{$index.title}}, {{$index.slug}}, and so on -
$index means "the current item".Additional Repeater settings:
| Setting | What it does |
|---|---|
| Tag | Wrap the output in div, ul, ol, or nothing |
| Limit | Cap how many items render |
| Filter / Sort | Available when the source is a collection that declares them |
| Pagination | Adds paging, by query string or URL segment |
Two Repeaters can point at the same collection independently - each keeps its own list, so a "Latest posts" and a "Popular posts" section on one page do not interfere.
While a collection loads in the builder, the Repeater shows skeleton placeholders. An empty Repeater shows a prompt to choose a collection; an empty Repeater Item prompts you to add children.
The same data can decide whether a block renders at all - a badge that only appears on featured posts, a section that only appears when a field is filled in. That is a separate setting on each block, covered in Conditional visibility.
Bindings work in page metadata and JSON-LD too, which is usually where a dynamic page needs them most:
Title: {{blog.title}} | Acme
Description: {{blog.excerpt}}
OG image: {{blog.coverImage.url}}
See Preview and publish.
A binding that resolves to nothing renders as an empty string, on the canvas and on the
published page. Hello {{user.nickname}} with no nickname renders Hello . There is no
error, no placeholder, and no broken page.
That is deliberate - a missing optional field should not take a page down - but it means a typo fails quietly. Two habits catch it:
{{blog.subtitle ?? 'Read more'}}.A collection that fails to load renders zero items rather than an error.
Collections only resolve inside a Repeater. Writing a collection binding into a plain text field fetches nothing. Lists need the Repeater.
Nested Repeaters share one $index. Only the innermost repeater context is addressable,
so a repeater inside a repeater cannot reach the outer item. Flatten the data in the
provider instead.
Filter, sort, and limit behavior depends on the collection. A collection declares which filters and sort orders it supports; ones adapted straight from a Payload collection ship without them, so those dropdowns are empty and the item cap is applied after fetching rather than in the query. For large collections, add limit and filter handling in the collection's own fetch function.
Pagination needs a Pagination block. The Repeater's pagination setting expects a
registered Pagination block. Without one, the controls render but do nothing. See
Custom blocks.
Image props replace rather than concatenate. For image fields, a bound value replaces the whole value, so a placeholder URL followed by a binding resolves to just the bound URL.
Validation in the builder is looser than on the server. A binding badge can look valid in the panel and still resolve to empty on the published page if it references a root key the server data does not actually have. Check the published page, not only the canvas.
Draft data in the builder, published data on the site. The builder loads providers in draft mode. A page that looks right in the builder can be empty live if the underlying record is still a draft.
The public API for binding is in chaipro/utils and chaipro/types:
import { applyChaiDataBinding } from 'chaipro/utils'
import type {
ChaiGlobalDataProvider,
ChaiPageTypeDataProvider,
ChaiBlockDataProvider,
ChaiCollectionEntry,
} from 'chaipro/types'
applyChaiDataBinding(block, pageData) resolves the bindings on a single block, for custom
render pipelines. The standard page route already does this - getPagePayload builds the
data object and RenderChaiBlocks receives it as pageData:
const { page, settings, pageData, pageProps } = await cb.getPagePayload(slug)
return (
<RenderChaiBlocks
pageData={pageData}
settings={settings}
page={page}
pageProps={pageProps}
draft={isEnabled}
/>
)
A collection is any object with a fetch that returns { items, totalItems }, so a
collection can be backed by Payload, an external API, or a filesystem - the Repeater does not
care. Declared filters and sort options are what populate the Repeater's dropdowns, and
your fetch receives the block and page props so it can honour them.
Data binding can be turned off for a project with the dataBinding feature flag, which
hides the binding editor, the field picker, and the canvas toggle.
{{ survives into the HTML.<head>, not just the panel.