ChaiBuilder Logo

Data Binding

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.

Where the data comes from

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.

Wiring it up

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.

Writing a binding

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.

Paths and pipes

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.

Path rules

  • Dots only: blog.author.name.
  • Array items use a numeric segment: 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}}.
  • No spaces inside a path, and no constructor, prototype, or __proto__ segments.
  • Only a record's own fields resolve. An inherited property reads as missing.

Pipe rules

  • The syntax is path | name arg arg, with arguments separated by spaces.
  • Arguments are literals only: 'quoted strings', numbers, true, false, null. You cannot pass another data path as an argument.
  • Every pipe declares the input types it accepts. Feeding it the wrong type invalidates the whole binding rather than guessing, so {{blog.title | join ', '}} on a string renders empty.
  • Pipes run left to right, each one receiving the previous one's output.
  • Limits: 10 pipes per binding, 10 arguments per pipe, 500 characters, one line.

Built-in pipes

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.

When a binding is invalid

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.

Repeating over a list

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.

  1. Add a Repeater block.
  2. In its settings, choose a source. Either a registered collection (Blogs, Legal) or an array already on the page, such as {{blog.tags}}.
  3. Inside it, a single Repeater Item holds the template. Build one card.
  4. Bind the fields inside that card with {{$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.

Conditional visibility

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.

SEO and structured data

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.

What renders when data is missing

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:

  • Toggle binding preview off to read the raw bindings on a block.
  • Give optional fields a fallback: {{blog.subtitle | default 'Read more'}}.

A collection that fails to load renders zero items rather than an error.

Migrating from JavaScript expressions

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.

Gotchas

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.

For developers

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.

Custom pipes

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.

Auditing existing bindings

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.

Verify

  1. With binding preview on, the canvas shows real values.
  2. With it off, every dynamic field shows the binding you expect.
  3. No badge in the settings panel is red.
  4. Visit two different records at the same page type and confirm both render correctly.
  5. View source on the published page and confirm no {{ survives into the HTML.
  6. Check the page title and meta description in the rendered <head>, not just the panel.

© ChaiBuilder. All rights reserved.