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}}
{{blog.publishedAt | date 'long'}}
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
binding is not valid, with the reason on hover.
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.
Click the badge. Clicking an existing badge opens a small editor on it, showing the data path it resolves, the formatters applied to it, an Add formatter picker limited to the ones that accept the current value, controls for each formatter's arguments, arrows to reorder them, and a live Preview against this page's data. It is the fastest way to work, because it will not let you build a binding the renderer would reject.
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 a data path, optionally followed by one or more pipes that format the value on its way to the page:
{{blog.title}}
{{blog.title | uppercase}}
{{blog.subtitle | default 'Read more'}}
{{blog.publishedAt | date 'long'}}
{{blog.price | currency 'USD' 2}}
{{blog.tags | join ', '}}
{{blog.title | trim | uppercase}}
There is no JavaScript in a binding. Arithmetic, comparisons, ternaries, ??, method
calls, and template literals are not evaluated. A binding containing any of them is invalid
and renders as an empty string. Anything a path plus pipes cannot express belongs in the data
provider that produces the value, or in a custom pipe.
That is a deliberate trade: bindings are authored by editors and run on your server during render, so the syntax is small enough to be checked completely rather than executed.
blog.author.name.blog.tags.0.name. Brackets are not supported -
blog.tags[0].name is invalid and renders empty.$index is the current item inside a Repeater: {{$index.title}}.constructor, prototype, or __proto__ segments.path | name arg arg, with arguments separated by spaces.'quoted strings', numbers, true, false, null. You
cannot pass another data path as an argument.{{blog.title | join ', '}} on a string renders
empty.| Pipe | Arguments | What it does |
|---|---|---|
default |
fallback literal (required) | Replaces null, undefined, "", and empty arrays with the fallback |
trim |
- | Strips leading and trailing whitespace |
uppercase |
- | chai becomes CHAI |
lowercase |
- | CHAI becomes chai |
capitalize |
- | Uppercases the first character only |
join |
separator (default ,) |
Turns an array into a string |
number |
decimal places | Locale number formatting: 1234.5 becomes 1,234.50 |
money |
mask (required, default $xx), decimal places |
Formats the number and substitutes the one xx token in the mask |
currency |
ISO code (required, default USD), decimal places |
Locale currency formatting |
date |
style short / medium / long / full (default medium), timezone (default UTC) |
Locale date formatting |
number, money, currency, and date format for the page's language, falling back to
the site's fallback language and then to en. The same binding renders 1,234.50 on the
English page and 1.234,50 on the German one.
Two details worth knowing: money needs exactly one xx token in its mask, so '$xx' and
'xx USD' work while '$' and 'xx-xx' render empty; and date accepts a Date, an ISO
string, or a timestamp, and renders empty for anything it cannot parse.
There is a second group of pipes - equals, notEquals, gt, gte, lt, lte, not,
truthy, empty - that return true or false. They are reserved for
Conditional visibility and render empty in a
normal text field.
Pipes is the full reference: accepted and returned types for every pipe, the chaining rules, and how to register your own.
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.
An unknown pipe, a bad argument, a type mismatch, a bracket, or a leftover JavaScript expression all resolve the same way: the binding renders as an empty string, the page still renders, and a warning is logged on the server in development. The badge turns red in the panel with the reason, and for a few common shapes it offers a Convert to pipes button that rewrites the expression for you.
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 | default 'Read more'}}.A collection that fails to load renders zero items rather than an error.
Earlier versions ran a small JavaScript subset inside {{ }}. That engine has been removed.
Pages authored against it keep rendering, but any binding that was more than a path now
renders empty until it is rewritten, so a page built before the change is worth a pass with
binding preview on.
The common shapes translate directly:
| Old expression | Now |
|---|---|
{{blog.title.toUpperCase()}} |
{{blog.title | uppercase}} |
{{blog.title.trim()}} |
{{blog.title | trim}} |
{{blog.tags.join(', ')}} |
{{blog.tags | join ', '}} |
{{blog.subtitle ?? 'Untitled'}} |
{{blog.subtitle | default 'Untitled'}} |
{{blog.status === 'live'}} |
{{blog.status | equals 'live'}} (visibility only) |
{{blog.price * 1.2}} |
compute the field in the provider, or register a custom pipe |
{{a + ' ' + b}} |
two bindings in one field: {{a}} {{b}} |
{{x > 0 ? 'yes' : 'no'}} |
not expressible; expose the label from the provider |
Those first five are exactly the cases the badge's Convert to pipes button handles. The rest need the value shaped upstream, which is where derived values belonged anyway - they are then computed once per request instead of once per binding.
To audit at scale rather than page by page, analyzeChaiBindings classifies every binding on
a set of blocks as path, pipe, or invalid, with a suggested conversion where one exists.
See For developers.
JavaScript in a binding renders empty. No arithmetic, comparisons, ternaries, ??, or
method calls. This is the single biggest change from earlier versions.
Boolean pipes are for visibility only. {{blog.price | gt 0}} in a text field renders
empty. Use them in the Visibility condition instead.
Pipe arguments are literals. {{blog.price | currency blog.currencyCode}} is invalid;
pass 'USD', or return a preformatted string from the provider.
A type mismatch invalidates the whole binding. join on a string, trim on a number, and
date on an unparseable value all render empty rather than coercing. The panel's formatter
picker only lists pipes that accept the current value, which is the easiest way to avoid this.
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.
Anything the built-ins do not cover is a pipe you register, which then appears in the formatter picker for every editor on the project:
import { registerChaiPipe } from 'chaipro/registry'
registerChaiPipe({
name: 'readingTime',
label: 'Reading time',
description: 'Turns a word count into "N min read".',
accepts: ['number'],
returns: 'string',
args: [{ name: 'wordsPerMinute', label: 'Words per minute', type: 'number', default: 200 }],
transform: ({ value, args }) => `${Math.ceil(Number(value) / Number(args[0] ?? 200))} min read`,
})
See Pipes for the definition fields, the argument schema, where registration has to run, and the rules a transform has to follow.
import { analyzeChaiBindings } from 'chaipro/utils'
const findings = analyzeChaiBindings(page.blocks).filter((b) => b.classification === 'invalid')
Each finding carries the block id, the property path, the expression, the reason it is
invalid, and suggestedConversion when the expression maps onto pipes. Useful as a one-off
script over existing pages after upgrading.
{{ survives into the HTML.<head>, not just the panel.