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.

A data binding is a data path, and a pipe is a named formatter applied to whatever that path resolves to:
{{blog.publishedAt | date 'long'}}
{{blog.price | currency 'USD' 2}}
{{blog.title | trim | uppercase}}
Pipes are the only transformation a binding can do. There is no JavaScript inside {{ }}, so
anything the built-ins do not cover is either shaped in the data provider or added as a pipe
you register yourself. Once registered, a pipe is available to every editor on the project in
the settings panel, with the same name, arguments, and preview as the built-ins.
This page is the full reference: what each built-in does, the type rules that decide which pipes can follow which, and the registry API.
Every pipe declares what it returns, and that splits them in two.
| Returns | Where it can be used | |
|---|---|---|
| Value pipes | a formatted value | Any bindable text prop, and inside a visibility condition as long as a boolean pipe finishes the chain |
| Boolean pipes | true or false |
Conditional visibility only |
A boolean pipe in a text field is rejected: {{blog.price | gt 0}} in a heading renders
empty, because a visibility test is not a value. The formatter picker in the panel enforces
the same split, so it only offers boolean pipes on the Visibility condition.
path | name arg arg | name arg
|.'quoted strings', numbers, true, false, null. A
data path cannot be an argument, so {{blog.price | currency blog.currency}} is invalid.\', \", \\, \n, \t escape inside them. A
quoted argument may contain |.| Pipe | Accepts | Returns | Arguments | Notes |
|---|---|---|---|---|
default |
any | any | value literal (required) |
Substitutes the fallback when the input is null, undefined, "", or an empty array |
trim |
string | string | - | Leading and trailing whitespace |
uppercase |
string | string | - | |
lowercase |
string | string | - | |
capitalize |
string | string | - | First character only. The rest is left as-is, so iPhone SE is not lowercased |
join |
array | string | separator string (default ,) |
|
number |
number, string | string | fractionDigits number |
Locale formatting. Numeric strings are accepted; anything non-numeric renders empty |
money |
number, string | string | mask string (required, default $xx), fractionDigits number |
Formats the number, then replaces the xx token in the mask |
currency |
number, string | string | currency string (required, default USD), fractionDigits number |
Locale currency formatting |
date |
date, string, number | string | style one of short, medium, long, full (default medium), timeZone string (default UTC) |
Accepts a Date, an ISO string, or a timestamp |
Details that decide whether output appears at all:
fractionDigits is honoured only as an integer from 0 to 20. Anything else is ignored and
the locale default applies, rather than erroring.money requires exactly one xx token: '$xx' and 'xx USD' work, '$' and
'xx-xx' render empty.currency needs a three letter ISO code. 'usd' is fine, 'dollars' renders empty.date renders empty for anything it cannot parse, and formats in UTC unless you pass a
timezone. That default is deliberate: a published page is cached and shared, so a server
local timezone would leak into the HTML.Only for the Visibility condition. All of them return a real true or false, which is what
the visibility rule requires.
| Pipe | Accepts | Arguments | True when |
|---|---|---|---|
equals |
any | value literal (required) |
The input is exactly the argument. Object.is, so no type coercion: '5' does not equal 5 |
notEquals |
any | value literal (required) |
The input is anything else |
gt |
number, string | value number (required) |
Input is greater than the argument |
gte |
number, string | value number (required) |
Input is greater than or equal |
lt |
number, string | value number (required) |
Input is less than |
lte |
number, string | value number (required) |
Input is less than or equal |
not |
any | - | The input is falsy |
truthy |
any | - | The input is truthy |
empty |
any | - | The input is null, undefined, "", or an empty array |
The four comparisons convert numeric strings, so '42' | gt 0 is true. Anything that is not
a finite number on either side is false rather than an error.
Each pipe declares the input types it accepts, and the runtime checks the actual value against that list before calling it. A mismatch invalidates the whole binding, which renders empty in a text prop and hides the block in a visibility condition. Nothing is coerced on your behalf.
The types are string, number, boolean, array, object, date, null, and any.
Two consequences worth internalising:
A missing value has type null. So {{blog.subtitle | uppercase}} on a record with no
subtitle is invalid, not empty-then-uppercased, because uppercase accepts only string.
default goes first, not last. default accepts any, so it is the pipe that turns a
missing value into something the rest of the chain can work with:
{{blog.subtitle | default 'Read more' | uppercase}} works
{{blog.subtitle | uppercase | default 'READ MORE'}} invalid when subtitle is missing
In the panel this is hard to get wrong, because the Add formatter picker only lists pipes that accept the value currently flowing into that position. Hand-typed bindings are where it bites.
number, money, currency, and date format through Intl using 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, with no per-language
authoring.
Custom pipes receive the same locale string, so a pipe that formats anything human-readable
should use it rather than hard-coding a format.
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`,
})
Editors then get Reading time in the formatter picker on any number field, with a
Words per minute input and a live preview, and {{blog.wordCount | readingTime 200}} renders
on the page.
| Field | Required | Purpose |
|---|---|---|
name |
yes | The identifier used in bindings. Must match ^[a-z][A-Za-z0-9]*$, so camelCase starting lowercase. An invalid name throws at registration |
label |
yes | What the panel shows in the picker and on the pipe card |
description |
no | One line shown under the label in the panel, and given to the AI assistant |
accepts |
no | Input types this pipe handles. Omitted or ['any'] means anything. This is both the runtime check and what the picker filters on |
returns |
no | Output type. 'boolean' marks the pipe as visibility-only |
args |
no | Argument schema. Drives both parsing and the panel controls |
transform |
yes | The function. Must be synchronous |
| Field | Purpose |
|---|---|
name |
Identifier, used in validation messages |
label |
Control label in the panel. Falls back to name |
type |
string, number, boolean, select, or literal. Checked when the binding is parsed |
required |
A binding missing a required argument is invalid |
default |
Prefilled when the editor adds the pipe |
options |
For select: the allowed { label, value } pairs. A value outside the list is rejected |
Arguments are positional. Required ones must come before optional ones, since an editor cannot skip a slot.
type: 'select' is worth reaching for whenever the set of valid arguments is closed, because
it turns a free text field into a dropdown and makes a wrong value impossible rather than
merely invalid:
registerChaiPipe({
name: 'truncate',
label: 'Truncate',
accepts: ['string'],
returns: 'string',
args: [
{ name: 'length', label: 'Max characters', type: 'number', required: true, default: 80 },
{
name: 'ellipsis',
label: 'Ellipsis',
type: 'select',
default: '...',
options: [
{ label: 'Three dots', value: '...' },
{ label: 'Unicode ellipsis', value: '…' },
{ label: 'None', value: '' },
],
},
],
transform: ({ value, args }) => {
const text = String(value ?? '')
const max = Number(args[0])
return text.length > max ? `${text.slice(0, max)}${args[1] ?? '...'}` : text
},
})
transform receivestransform: ({ value, args, locale, propertyKey }) => unknown
| Field | What it is |
|---|---|
value |
The output of the previous pipe, or the resolved data path for the first pipe |
args |
The parsed literal arguments, in order |
locale |
The page's language, already resolved through the fallback chain |
propertyKey |
The block prop being bound, for example content or image. Rarely needed |
The registry is an in-memory map, per process, so it has to be populated before anything renders, on both the builder side and the public side. Register pipes wherever the project already registers custom blocks, at module scope rather than inside a component, so a cold serverless start populates the registry before it serves its first request.
Registering only on the public side is the failure worth watching for: pages render correctly, but editors never see the pipe in the panel and every binding using it shows as invalid in the settings panel.
The registry helpers are exported alongside the rest:
| Export | Use |
|---|---|
registerChaiPipe(definition) |
Add or replace a pipe |
getRegisteredChaiPipes() |
Every registered pipe, built-ins included |
The registered pipes are injected into the AI page and block editing prompts, described by
name, accepted and returned types, arguments, and description. A pipe you register is
therefore something the assistant can reach for on its own, which makes description worth
writing as an instruction rather than a restatement of the label: "Turns a word count into
'N min read'" tells the assistant when to use it, "Reading time pipe" does not.
A boolean pipe in a text field renders empty. It is not a formatting bug. Move the condition to Visibility.
accepts is a promise you have to keep. It gates which values reach your transform, so
declaring ['any'] on a pipe that calls string methods pushes the failure into the transform
and turns a clear "does not accept number" message into a silently empty binding.
A pipe cannot see the page data. It receives one value and its literal arguments, nothing else. Anything needing two fields together is a computed field on the provider.
Renaming a pipe breaks existing pages. The name is stored inside every binding that uses it, and an unknown pipe is invalid. Treat pipe names as public API once editors have used them; register the old name as an alias if you must rename.
Warnings are development only. Invalid bindings and failing pipes log to the server
console with NODE_ENV other than production, once per unique expression. In production
they fail silently, so upgrades want a check on the pages themselves.
{{ }} syntax pipes live in