App Builder Guide
Table of Contents
- Overview
- Getting Help in the Editor
- Data Model
- The Grid
- Block Types
- Block Palette
- Add-Block Wizard
- Arranging Blocks
- Appearance
- Conditional Formatting
- Themes
- Layout & Nesting
- Building a Page
- Block Config
- Block Click Actions
- Cross-Block Reactivity
- Chart Drill-Down
- Expressions & Bind Picker
- Input Blocks: Types & Validation
- Date Range Kinds
- Upload Publishing
- Forms: Wizards, Conditions & Widgets
- Custom Forms & Workflows
- Custom Widgets
- Reports
- Permissions
- Public Pages
- Filters & Master-Detail
- Export & Import
- Build with AI (MCP)
- Best Practices
Overview
The App Builder turns your Baasix backend into a usable application without a second codebase. Admins compose pages from configurable blocks — a table over a collection, a kanban grouped by status, a form that creates records, a chart that aggregates on the server — and non-admin roles use those pages as their daily workspace, governed by the permissions you already have.
The builder and the end-user view are the same components: admins toggle an in-place "Editing" mode to add, configure, move, and delete blocks live on the page. All configuration is stored in the database, so it is versionable, exportable across instances, and editable through the UI, the SDK, or AI via MCP.
Getting Help in the Editor
Every field in the block editor carries inline help text describing its purpose.
Complex fields — filters, aggregates, record sources, actions, input names, and file
fields — show a "?" icon that opens a guide with a worked example. The fx
(expressions) toggle row always displays a guide explaining {{ }} expressions and
pointing at the Bind… button for visual binding. Expression help stays accessible
whether fx is on or off, so you can reference syntax while authoring.
Data Model
Two system collections back the App Builder:
baasix_Page
| Field | Type | Notes |
|---|---|---|
name | String | Display name |
slug | String | Unique per tenant; used in the route ?slug= |
icon | String | Lucide icon name for the menu |
description | Text (nullable) | |
parent_Id | UUID (self-FK, null) | Menu nesting |
sort | Integer | Menu ordering |
isPublic | Boolean (default false) | Reachable without login via /p/?slug= |
enabled | Boolean (default true) | Disabled pages are hidden from the menu and renderer |
options | JSON | Page-level settings (e.g. menuGroup, homeFor, theme) |
roles | JSON (nullable) | Role ids allowed to see the page in menus (null/empty = all) |
baasix_Block
| Field | Type | Notes |
|---|---|---|
page_Id | UUID FK | Owning page (onDelete: cascade) |
type | ENUM | One of the 53 block types (below) |
collection | String (null) | Bound collection (null for collectionless blocks) |
position | JSON | { row, col, span } on the 12-column grid |
parentBlock_Id | UUID (self-FK, null) | Parent container block (tabs/container/modal); null = top level |
slot | String (null) | Slot within the parent: tab:<index> for tabs, body otherwise |
config | JSON | Per-type configuration |
configVersion | Integer | For config-format migrations |
Pages and blocks are ordinary rows — you read and write them with the same items API as any collection.
The Grid
Pages use a 12-column grid. Each block declares position: { row, col, span }:
row≥ 0 — rows stack verticallycol0–11 — starting columnspan1–12 — how many columns the block occupies
Blocks within a row share the 12 columns; there is no free positioning and no overlap. On mobile, rows collapse to a fully stacked layout (every block spans the full width).
Block Types
There are 53 block types, grouped into six categories. The same grouping drives the block palette in the UI editor.
Data blocks (bind to a collection):
| Block | What it shows |
|---|---|
table | Data grid: columns, filters, sort, inline edit, actions |
form | Create/edit records — flat, or a multi-step wizard |
details | Read-only field view of a single record |
kanban | Drag-to-update board grouped by a field |
calendar | Records mapped to date fields (month/week/day); multi-day events that span the visible range boundary are fetched too |
chart | 9 chart kinds with server-side aggregation |
cardlist | Card/grid layout with title, image, fields |
map | Map with markers, clustering, popups — Leaflet or Google Maps |
geochart | Choropleth world map shaded by an aggregated value |
feed | Realtime message/notes thread with a composer |
media | Image/video/audio gallery from a file field |
timeline | Vertical event timeline with day grouping, icons, colors |
progress | KPI vs target as a progress bar or radial gauge |
repeater | A markdown template rendered once per record ({{field}}) |
report | The reports endpoint (aggregate + groupBy) as a table with CSV export |
filter | Publishes filter state to sibling blocks |
Input blocks (collectionless or optional — publish a value as $input.<name> for sibling
blocks' filters, see Filters & Master-Detail):
| Block | Binds to a collection? | What it publishes |
|---|---|---|
input | no | Standalone text/select/date/toggle input driving sibling blocks' filters ($input.<name>) |
select | optional | Dropdown — static options or a collection's rows; $input.<name> |
daterange | no | Date or date-range picker with presets; $input.<name>_from / _to (single mode: $input.<name>) |
slider | no | Numeric slider, single or two-thumb range; $input.<name> (range mode: _min / _max) |
switch | no | Toggle or segmented control; publishes a value, or clears the filter condition when toggled off |
rating | optional | Star rating — input mode publishes $input.<name>; display mode renders a bound record's numeric field |
Display blocks (bind to a collection — render a record or aggregate; don't publish selections beyond tree):
| Block | What it shows |
|---|---|
stat | KPI tiles — one aggregate per tile, with an optional compare-filter delta and sparkline |
tree | Hierarchy from a self-referencing collection; clicking a node publishes a selection |
steps | Step/progress indicator driven by a bound record's status field |
badge | A record field's value as a colored chip; or distinct field values with counts |
countdown | Countdown timer to a target date/time, or a live clock |
avatar | Row of user avatars from a collection's name/image fields |
comparison | Two aggregated metrics side by side with a computed delta |
keyvalue | A record's fields as a compact labeled-row grid |
leaderboard | Top-N ranked list of grouped records with a metric and bars |
Navigation blocks (collectionless):
| Block | What it does |
|---|---|
breadcrumbs | Trail of parent pages — automatic from page hierarchy, or manual |
links | Grid or list of shortcut buttons using the block action system |
header | Hero heading with subtitle, description, icon, and action buttons |
Layout blocks (collectionless):
| Block | What it does |
|---|---|
tabs | Tab strip; each tab hosts its own nested grid (lazy-mounted) |
container | Card or plain section around a nested grid; optionally collapsible |
modal | Hidden dialog hosting blocks — opened by an "open modal" action |
divider | Horizontal rule with an optional label |
spacer | Fixed-height vertical gap between blocks |
subpage | Embeds another builder page by slug, rendered one level deep |
tabs, container, and modal host child blocks (see Layout & Nesting);
spacer and subpage don't.
Content blocks:
| Block | Binds to a collection? | What it shows |
|---|---|---|
markdown | no | Static markdown content |
richtext | optional | Rich text (lexical editor) — static, or a record's HTML field |
buttons | no | Link / workflow / modal / page action buttons |
iframe | no | Embed an external URL |
upload | no | File upload widget |
code | optional | Code/JSON viewer (live record field or static) |
video | optional | Video or audio from a URL (YouTube/Vimeo/direct) or a file field |
pdf | optional | Inline PDF viewer from a URL or a file field |
carousel | optional | Image slideshow from a static list or a file field, with optional autoplay |
alert | no | Callout banner with tone (info/success/warning/error), markdown body, optional dismiss |
html | no | Static HTML rendered inside a sandboxed iframe |
image | optional | Image from a URL or a file field, with fit/aspect ratio and an optional click action |
widget | optional | Sandboxed custom HTML/JS with a window.Baasix bridge to page context and bound data |
Block Palette
The in-place UI editor's Add-block picker is categorized by the same six groups above
(Data / Input / Display / Navigation / Content / Layout), searchable by block name or
description, and shows each block's icon and a one-line description before you place it.
Blocks that needsCollection: true prompt for a collection immediately after being added;
blocks with needsCollection: "optional" (e.g. select, rating, video, pdf, carousel,
richtext, code, image, widget) can be added collectionless and bound later, or left static.
In editor mode, the palette also renders as a tray alongside the canvas — drag a block type from the tray and drop it directly on the page to create it at that spot, then its settings sheet opens for configuration (collection, if required, and the rest of its config).
Add-Block Wizard
When you add a block from the palette in edit mode, the Add Block sheet now guides you in two steps:
- Search & select — a full-sheet searchable palette of all available block types, grouped by category, with each block's icon, name, and description visible.
- Configure — once selected, the sheet transitions to block-type-specific settings (collection binding, placement, initial config). A back button lets you change your block choice.
This replaces the old single-step picker, making it easier to discover blocks and correct misselections without starting over. Edit mode remains unchanged for existing blocks.
Arranging Blocks
In editor mode (admin + the "Editing" toggle), every block on the canvas shows a grip handle and a right-edge resize handle, replacing the old move-up/down, swap-left/right, resize-±, and "move into" buttons:
- Reorder — drag a block by its grip handle to reorder it within a row, move it across rows, or drop it between two rows to start a new one. Blocks in a row lay out left-to-right in their order.
- Resize — drag the right-edge handle to change the block's
span(1–12 columns), snapping to the 12-column grid as you drag. - Nest — container, tab, and modal bodies are drop zones: drag a block onto one to nest it inside, or drag it back out onto the page to return it to the top level. The same nesting rules apply as before (max depth 3, no dropping a block into its own descendant) — see Layout & Nesting.
- Keyboard — focus a block's grip handle, press Space to pick it up, use the arrow keys to move it, and press Space again to drop it.
- Exact placement — a block's settings sheet still has numeric Row / Col / Span fields for precise placement, as an alternative to dragging.
Edit and Delete stay as buttons on each block. Drag-and-drop arrangement is
editor-only — published/runtime pages render the saved position and are unaffected.
Appearance
Every block's settings sheet has an Appearance section, independent of the block's type-specific config, and fully honored by every block type:
| Setting | Values | Notes |
|---|---|---|
| Background | theme token or hex color | |
| Border color | theme token or hex color | |
| Border width | 0–8px | |
| Corner radius | 0–32px | |
| Shadow | none / sm / md / lg | |
| Padding | none / sm / md / lg | |
| Hide card chrome | boolean | Drops the card background/border/shadow — content only |
| Title alignment | left / center / right | |
| Title size | sm / md / lg | |
| Title color | theme token or hex color | |
| Accent color | theme token or hex color | Drives stat tile values, steps indicators, rating stars, and badge accents |
Color fields (background, borderColor, titleColor, accent) accept either a theme
token (primary, secondary, muted, accent, destructive, …) or a custom hex
color (#22c55e). Prefer theme tokens — they track light/dark mode and future theme
changes automatically; reach for a hex value only when a block must match a fixed brand
color regardless of theme.
Conditional Formatting
A shared format rule model lets a block change a value's color, weight, or icon based on the data itself — a status column colored red when overdue, a KPI tile that turns green above target. One rule shape is reused across every block that supports it:
{
field: string; // record field to test (dotted paths supported, e.g. "author.name")
operator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "empty" | "notEmpty";
value?: string | number; // required for every operator except empty / notEmpty
style: {
textColor?: string; // theme token or hex, same acceptance as Appearance colors
background?: string;
icon?: string; // lucide icon name
bold?: boolean; // at least one style property is required
};
}Rules are evaluated in order and the first match wins — once a rule matches, later rules in the list are never checked. A record that matches nothing keeps the block's normal styling.
Where rules live and what they affect differs by block:
| Block | Config key | Effect |
|---|---|---|
table | formatting.columns.<field> (rule list per column) | Text color / icon / bold on the matching cell |
table | formatting.rows (flat rule list) | Row background tint — row rules support only style.background |
details, keyvalue | formatting (flat rule list) | Each rendered field is matched by rule.field against the whole record |
cardlist, kanban | formatting (flat rule list) | Accent-only: a matching rule paints a 3px left border (textColor, falling back to background) — no fill or text recoloring |
stat | per-tile formatting | Matched against { value }; overrides that tile's accent color (and bold/icon) |
comparison | formatting (flat rule list) | Matched against the left value only; overrides its color |
leaderboard | formatting (flat rule list) | Matched per row against { value }; overrides that row's bar/label color |
progress | formatting (flat rule list) | Matched against { value: current }; overrides the bar/gauge color computed from thresholds |
progress also keeps its earlier thresholds: [{ upTo, color }] array (first threshold
whose upTo is ≥ the current percentage wins) as the base color — formatting is layered
on top and wins if both are present and a rule matches.
Themes
The baasix_Theme collection stores reusable color/radius sets for the app shell and
public pages:
| Field | Type | Notes |
|---|---|---|
name | String | |
tokens | JSON | { light?: {...}, dark?: {...} } — one value per CSS token |
isDefault | Boolean | Default theme for its scope |
tenant_Id | UUID (nullable) | Null = global theme; set = tenant-scoped, unique per tenant + name |
Each token set carries a value for every standard token: background, foreground,
card, card-foreground, popover, popover-foreground, primary,
primary-foreground, secondary, secondary-foreground, muted, muted-foreground,
accent, accent-foreground, destructive, destructive-foreground, border, input,
ring, chart-1…chart-5, and radius. Color tokens are HSL triples with no hsl()
wrapper (e.g. "222.2 47.4% 11.2%"), injected as-is into --<token> CSS custom
properties; radius is a plain CSS length (e.g. "0.5rem").
Manage themes from the Themes admin screen: create/edit a theme's light and dark token sets with a live preview, and mark one theme as the default per scope (global, or a given tenant).
A page picks its theme in Page settings — a preset picker (options.theme.themeId)
plus optional primary color / accent color / radius overrides layered on top
(options.theme.overrides), applied per light/dark scheme. A page with no theme selection
falls back to its tenant's default theme, or the global default.
Themes referenced by exported pages ride along in page-bundle export/import:
import resolves themes by name (creating one if no match exists on the target instance) and
remaps each page's themeId accordingly; an existing default theme on the target is never
demoted by an imported one.
Layout & Nesting
tabs, container, and modal blocks host child blocks: a child carries
parentBlock_Id (the container's id) and a slot (tab:<index> inside tabs, body
otherwise), and lays out on its own nested 12-column grid. Nesting rules are enforced
server-side: the parent must be a container block on the same page, no cycles, max depth 3.
Deleting a container cascades to its children.
In the UI editor, container/tab/modal bodies are drop zones — drag a block onto one to nest it, or drag it back out onto the page to return it to the top level. See Arranging Blocks.
- Tabs panels mount lazily — a hidden tab's blocks don't fire their queries until first opened.
- Modals are hidden at rest and open via the
{ type: "modal", blockId }action available to buttons blocks, header actions, and row actions; their children mount only while open — a "New record" form in a modal costs nothing until clicked. - Containers can be collapsible (
collapsible,defaultCollapsed) and render as a card (default) or a plain group.
Building a Page
The fastest way is the in-place UI editor: as an admin, open a page, flip the "Editing" toggle, and add blocks. You can also build pages programmatically over the SDK — pages and blocks are just collections:
// Create the page
const page = await baasix.items('baasix_Page').create({
name: 'Operations',
slug: 'operations',
icon: 'gauge',
});
// Add a table block bound to the orders collection
await baasix.items('baasix_Block').create({
page_Id: page.id,
type: 'table',
collection: 'orders',
position: { row: 0, col: 0, span: 8 },
config: {
columns: [{ field: 'id' }, { field: 'customer' }, { field: 'total' }],
filter: { status: { eq: 'open' } },
sort: 'createdAt:desc',
actions: { create: true, edit: true, view: true },
},
});
// Add a status chart beside it
await baasix.items('baasix_Block').create({
page_Id: page.id,
type: 'chart',
collection: 'orders',
position: { row: 0, col: 8, span: 4 },
config: {
chartType: 'doughnut',
aggregate: { count: { function: 'count', field: 'id' } },
groupBy: ['status'],
},
});The page renders at /pages/?slug=operations (authenticated) — public pages render at
/p/?slug=<slug>.
Block Config
Every block carries a JSON config. The shape depends on the block type, but the
common envelope is { title?, description?, ...typeSpecific }. A few examples:
- table —
columns[{ field, label?, format? }],filter?,sort?,pageSize?,actions{ create?, edit?, delete?, view? },search?,inlineEdit? - kanban —
groupByField,cardTitleField,cardFields?[],allowDrag? - calendar —
startField,endField?,titleField,views?[] - chart —
chartType,aggregate{ alias: { function, field } },groupBy?[],filter? - form —
mode: "create"|"edit",fields[{ field, required?, visibleWhen?, widget? }]or wizardsteps[],successMessage? - tabs —
tabs[{ label, icon? }]; modal —width?; progress —aggregate,target(number or a second aggregate),variant: "bar"|"radial" - map —
geometryField,provider?: "leaflet"|"google"(Google needs a Maps JS API key saved in project settings asmetadata.googleMapsApiKey; without one it falls back to Leaflet) - subpage —
slug(the embedded page's slug; embeds one level deep only — a subpage that itself contains asubpageblock does not recurse) - comparison —
leftLabel,leftAggregate,leftFilter?,rightLabel,rightAggregate,rightFilter?,format?: "number"|"currency"|"percent",prefix?,suffix? - leaderboard —
groupField,metric{ function, field },filter?,limit?(3–25, default 10),labelPrefix? - avatar —
nameField,imageField?,filter?,sort?,max?(default 5),avatarSize?: "sm"|"md"|"lg" - stat sparkline — per tile,
sparkline{ dateField, bucket: "day"|"week"|"month", points? }(6–60 buckets, default 12); the line reuses the tile's resolved accent color - badge —
mode: "record"|"distinct";distinctgroups all records byfieldon the server and renders one chip per distinct value with its count, instead of resolving a single bound record
Filter DSL — every
filtervalue uses the same items-API filter DSL as queries and permissions (eq,neq,gt,in,contains,between,dwithin, …) including the dynamic variables$CURRENT_USER,$CURRENT_TENANT, and$NOW±DAYS_N. There is no second filter language anywhere.
Block config is validated server-side on every create and update against the block
type and the bound collection's schema, so a page can never be saved into a broken state.
You can pre-check a config without writing via the MCP baasix_validate_block_config tool.
Block Click Actions
Many data blocks now support configurable actions when a user clicks a record:
- Tree, leaderboard, avatar —
itemActionin the block config runs an action (view record, open page, open modal, run workflow, or link) when a node, row, or avatar is clicked. - Stat tiles — each tile has an
actionfield (inside thetiles[N]array) for per-tile click behavior. - Map, feed —
itemActionruns an action on marker click (map) or message click (feed).
In all cases, the record selection is still published to sibling blocks — so a "view record" action runs and the record becomes available to a sibling details block. The action syntax is the same as buttons, headers, and row actions:
{
"type": "view", // or "link", "page", "modal", "workflow", "create"
"collection": "orders", // optional; defaults to the block's collection
"idField": "customerId" // optional; defaults to "id"
// ... other action-type-specific fields
}Configure click actions in each block's settings sheet under Click action (or On tile click for stat tiles).
Cross-Block Reactivity
Clicking a table row, cardlist/kanban card, calendar event, or map marker publishes that record as the block's selection. Sibling blocks consume it two ways — no page reload, no expression language:
1. Record sources. Record-bound blocks (details, form in edit mode, code /
richtext in record mode, and the new rating/badge/steps display blocks and
video/pdf/carousel in file-field mode) take a source (or recordSource):
// Details block follows whatever row is clicked in the table block
{ "fields": [{ "field": "name" }, { "field": "status" }], "source": { "type": "block", "blockId": "<table-block-id>" } }{ "type": "param" } (the default) keeps the classic ?id= URL behavior. In the UI
editor this is the "Record source" dropdown.
2. $selection filter placeholders. Any block's filter may reference the selected
record's fields:
// Chart re-aggregates for the clicked row's customer
{ "filter": { "customer_Id": { "eq": "$selection.<table-block-id>.customer_Id" } } }While no selection exists the block shows a "Select a record" state instead of querying
unfiltered. Selections are in-memory per page view; the ?id= URL param remains the
deep-linkable mechanism (row clicks still set it).
Input blocks extend the same idea to ad-hoc controls: an input, select, daterange,
slider, switch, or rating (input mode) block publishes its value as $input.<name>,
consumable in any sibling's filter:
// select block: { "name": "st", "optionsSource": "static", "options": [...] }
// table block: { "filter": { "status": { "eq": "$input.st" } } }Unset non-required inputs simply drop their condition; required: true makes consumers
wait ("Select a record") until a value is provided. Range-shaped inputs (daterange in
range mode, slider in range mode) publish two keys instead of one — see
Filters & Master-Detail for the full $input naming table.
Chart Drill-Down
Bar, line, area, pie, and doughnut charts now support drill-down filtering: clicking a category or slice publishes that category's value as a page input, which sibling blocks can consume to filter their data.
Add a clickInput field to your chart config with an identifier name:
{
"type": "chart",
"collection": "orders",
"config": {
"chartType": "bar",
"aggregate": { "count": { "function": "count", "field": "id" } },
"groupBy": ["region"],
"clickInput": "region"
}
}Clicking a bar publishes $input.region with the clicked category (e.g., "US"). A sibling
table or other block can then filter using {{ input.region }}:
// Table block filter
{ "region": { "eq": "{{ input.region }}" } }Clicking the same value again clears it, so users can toggle drill-down on and off.
Works on cartesian charts (bar/line/area) and pie/doughnut only; clickInput is
ignored on radar, polar, treemap, and stat charts. The clicked value is the raw grouped-by
field value (for geochart, it's the country's raw regionField value).
Expressions & Bind Picker
Beyond $param / $input / $selection placeholders in filters, most block-config fields
accept a full {{ expr }} expression: a single JavaScript expression (no statements),
evaluated in the visitor's browser against a context object:
| Name | Value |
|---|---|
param | The page URL's query params |
input | Page input values, keyed by input block name |
selection | Selected record by block id — selection.<blockId>.<field> |
user | { id, firstName, lastName, fullName, email, roleName } (best-effort; null on public pages) |
now | The current Date |
Typing rule. A field value that is entirely one {{ expr }} (nothing else in the
string, once trimmed) evaluates to the expression's raw result — so a number, boolean,
select, or json field can hold a real number, boolean, option value, or object:
{ "limit": "{{ input.pageSize ?? 25 }}" }
{ "target": "{{ selection.orders.total * 1.1 }}" }A value with any surrounding or mixed-in text interpolates every {{ }} run as a string
and concatenates the pieces — always yielding a string:
{ "title": "Welcome back, {{ user?.firstName }}" }An expression that throws or evaluates to undefined resolves to undefined, so the field
just falls back to its normal empty/default handling — a broken expression can't crash a
block or the page.
Expressions use the same trust model as computed table/details columns: they are admin-authored configuration, not user input, and run unsandboxed (no worker, no iframe) in the viewer's browser — the same as the rest of a block's config. Don't wire an expression to render or evaluate anything a non-admin user typed.
Forbidden fields. A few config fields must stay literal and reject {{ }} server-side:
any color value (Appearance colors, format-rule style colors, theme tokens), the
formatting / format-rules field itself, and field-picker / collection-picker fields (they
must always name a real field or collection).
In the in-place UI editor, eligible fields — text, number, boolean, select, json, markdown —
show a small fx toggle next to the label that swaps the normal control for a raw
expression textarea.
Bind Picker
Every expression-eligible field now shows a Bind… button that generates the {{ }} syntax
for you without typing. Click it to:
- Choose a data source: Selected row (from a data block), Page input (from an input block), URL param, User field, or Now (current date/time).
- Pick the field or value from dropdowns.
The picker generates the correct expression — e.g., {{ input.region }} for an input block named
"region", or {{ selection.customers.email }} for the email field of a clicked customer record.
This is especially useful for filters and expressions you're not sure how to write by hand. The Title field also has the Bind picker, allowing dynamic page titles based on page inputs or selected records.
Progress Target Expressions
The progress block now accepts expressions for its target field. Instead of just a fixed
number or a second aggregate, you can set target to a {{ expression }} string that resolves to
a number at render time:
{
"type": "progress",
"collection": "tasks",
"config": {
"aggregate": { "function": "count", "field": "id" },
"target": "{{ input.goal }}", // resolves page input named 'goal' to a number
"label": "Tasks / Goal",
"variant": "bar"
}
}This lets users set their own targets via an input block, or pull goals from a selected record's field.
Input Blocks: Types & Validation
The input block now supports multiple input types with built-in validation:
| Type | What it accepts | Validation |
|---|---|---|
text | Short text | minLength, maxLength, pattern (regex) |
textarea | Long text (multi-line) | minLength, maxLength |
number | Integer or decimal numbers | min, max, step |
decimal | Decimal numbers (alternate widget) | min, max, step |
email | Email address | Built-in email format check |
url | URL | Built-in URL format check |
password | Masked password input | minLength, maxLength, pattern |
tel | Phone number | No built-in format; use pattern for custom rules |
select | Dropdown (static or collection-bound options) | Optional required flag |
date | Calendar date picker | No additional validation |
toggle | Boolean switch / checkbox | Optional explicit-false mode (see below) |
Configure the type and validation rules in the input block's settings. Invalid values show an
inline error and the input is not published ($input.<name> stays undefined) until corrected.
Toggle: Explicit False
The switch (toggle) block has an optional "Explicit false" mode. By default, toggling off
clears the filter condition entirely. With explicit false enabled, toggling off publishes false
as the value — so a filter can distinguish between "toggle off" (filter by false) and "input
not set" (no condition). Enable this in the toggle's settings when your workflow needs to handle
three states: true, false, and absent.
Date Range Kinds
The daterange input block's "Kind" option controls what precision the picker displays:
date— Calendar days only; picker shows a month view. Single mode publishes a date; range mode publishes$input.<name>_fromand$input.<name>_to.time— Clock times only (HH:mm); picker shows a time input. Single mode publishes a time; range mode publishes_fromand_totimes.datetime— Full date + time picker with both calendar and clock. Single mode publishes an instant; range mode publishes_fromand_toinstants.
Each kind combines with single or range mode to fit different filtering scenarios — use date
for "orders between these two days", time for "events between 9am and 5pm", or datetime for
exact timestamp ranges.
Upload Publishing
The upload block can publish the uploaded file's id as a page input, making it available to
sibling blocks via {{ input.<name> }}. Set the block's Name field to enable this:
- Upload succeeds → file id is published as
$input.<name>(a string) - Other blocks can reference it in filters, expressions, or form fields
- Useful for multi-step workflows: upload a CSV in one section, then pass its id to a processor block
When a name is set, the upload block publishes after each successful upload; unset the name to disable publishing (upload remains a standalone file viewer).
Forms: Wizards, Conditions & Widgets
Form blocks go beyond a flat field list:
-
Wizard steps — group fields into steps with a progress header, back/next, and per-step required validation; one submit at the end:
{ "mode": "create", "steps": [ { "title": "Basics", "fields": [{ "field": "name", "required": true }, { "field": "status" }] }, { "title": "Details", "fields": [{ "field": "priority", "widget": "slider" }, { "field": "notes" }] } ] } -
Conditional fields — show a field only while another field's live value matches; hidden fields are excluded from validation and from the submit payload:
{ "field": "notes", "visibleWhen": { "field": "status", "operator": "eq", "value": "open" } }Operators:
eq,neq,in,notEmpty,empty. -
Field widgets — replace the schema-type default input per field:
rating(stars),slider,color,phone,currency,signature(canvas pad storing a PNG data-url), tuned withwidgetOptions { min, max, step, currency, maxRating }.
Custom Forms & Workflows
The form block's Submit target can now be a Workflow in addition to a collection.
Workflow mode lets you define custom fields inline (no collection needed) and submit their
values to a workflow — which can call third-party APIs, send emails, or trigger custom logic
via its HTTP node server-side (no CORS, secrets stay server-side).
When to use:
- Collecting feedback or contact info with no backend table needed
- Integrating with external APIs (Slack, Stripe, etc.) without writing a collection schema
- Sending emails or SMS on form submission
- Multi-step operations that don't fit a single create record
Setup:
- Create a workflow with an HTTP trigger (webhook) or use the execute route for logged-in pages
- In the form block, select Workflow as the submit target (instead of a collection)
- Define the form's fields inline (name, type, validation) — no collection binding needed
- The workflow receives the field values as its input
Public pages + workflows: Public forms submit through the workflow's webhook trigger (unauthenticated); logged-in pages submit through the execute route (requires the user's token). Both receive the submitted field values, so a single workflow can power both public sign-ups and authenticated feedback forms.
See Workflows for HTTP nodes and webhook triggers.
Custom Widgets
The widget block drops admin-authored HTML and JavaScript straight into a page, for the
rare view none of the 52 built-in blocks cover — a custom visualization, a third-party embed,
a bespoke interaction.
{
"type": "widget",
"collection": "orders",
"config": {
"html": "<div id=\"root\"></div><script>Baasix.onData(function (d) { document.getElementById('root').textContent = d.rows.length + ' orders'; });</script>",
"height": 200,
"allowInputs": true,
"filter": { "status": { "eq": "open" } },
"limit": 100
}
}Sandboxing. The widget's HTML renders inside an <iframe sandbox="allow-scripts"> —
never allow-same-origin. That gives the widget an opaque origin: no cookies, no
localStorage, no access to your session token or the app's own origin. Its only channel to
the outside world is a window.Baasix bridge:
| Method | Does |
|---|---|
Baasix.onContext(cb) | Fires with the page context (param/input/selection/user/now, same shape as Expressions) immediately and on every change |
Baasix.onData(cb) | Fires with { rows, total } from the block's bound collection query, when collection is set |
Baasix.setInput(name, v) | Publishes a page input value ($input.<name>) for sibling blocks — gated by allowInputs |
Baasix.resize(px) | Requests a new iframe height, clamped to 50–2000px |
The bound collection query itself runs outside the sandbox, in the page under the
viewer's own permissions — the widget only ever receives the resulting rows over
postMessage, never a token or direct API access. allowInputs (default true) gates
whether setInput calls actually publish; when off, calls are silently ignored.
Config: html (required), height (default 400), allowInputs (default true), and the
usual data-binding fields filter / fields / sort / limit (default 100) for the bound
collection.
Reports
The report block renders the reports endpoint (/reports/:collection) as a
read-only table — aggregate result sets the items-bound table block can't express:
aggregate aliases as columns, dotted relational fields merged onto grouped rows, and
date:-prefixed virtual buckets (date:month:createdAt):
{
"query": {
"aggregate": {
"total": { "function": "count", "field": "*" },
"revenue": { "function": "sum", "field": "amount" }
},
"groupBy": ["status"],
"sort": ["-total"]
},
"columns": [{ "field": "status", "label": "Status" }, { "field": "total", "label": "Orders" }, { "field": "revenue" }]
}Rows are aggregates, not records — no row actions or selection — and a header
export-csv action downloads the result set. The block's filter participates in page
filters and $param / $selection / $input resolution like any data block.
Permissions
The App Builder introduces no new ACL system — it rides baasix_Permission:
- All configuration is admin-only. Only administrators see the UI editor and can create/update/delete pages and blocks.
- Other roles get read-only access. Row-level conditions decide which pages and blocks a role can see — the sidebar menu is simply "the pages this role can read".
- Blocks never bypass data permissions. Every data block goes through the same collection permissions and row-level conditions as any other request. A block over a collection a role cannot read renders an empty/error state.
The roles field on a page controls menu visibility only; it is menu curation, not a
security boundary — data access is always enforced by baasix_Permission.
Public Pages
Set isPublic: true (and enabled: true) to make a page reachable without login at
/p/?slug=<slug>. Public pages are self-sufficient: the page's own config is served
anonymously, so you no longer need to grant the public role read access to baasix_Page
for a page to render.
The frontend fetches the page over a dedicated, unauthenticated endpoint —
GET /pages/public/<slug> — which returns only pages matching
isPublic: true AND enabled: true, and responds with a uniform 404 for everything else
(wrong slug, a page that exists but isn't public, or a disabled page) — there's no way to
distinguish "doesn't exist" from "exists but private" from the response. The response also
carries the tenant's branding (logo, project name, colors) and the page's resolved theme
(its own theme selection, falling back to the tenant's default), which the public shell
applies before rendering.
This endpoint only serves page and block configuration — it never returns record data. Each data block still fetches its rows through the normal items API, so the data itself remains governed by the public role's collection permissions exactly as before: grant the public role read access on whichever collections a public page's blocks are bound to, or those blocks render their normal empty/no-access state. Forms support an optional arithmetic human-check and a configurable success message / redirect, and public pages share the existing API rate limiting.
Filters & Master-Detail
-
Filter blocks publish filter state to sibling data blocks on the page (target specific blocks or
"all"), merged into each target's own config filter. -
Runtime placeholders — any data-block filter value may be one of:
"$param.<name>"— read from the page URL's query params (deep-linkable master-detail);"$selection.<blockId>.<field>"— the record last clicked in a sibling data block (see Cross-Block Reactivity);"$input.<name>"— an input block's current value.
While a referenced param or selection is absent, the block shows a "Select a record" state instead of querying unfiltered; unset non-required inputs strip silently.
-
$inputcomposite names. Most input blocks publish a single$input.<name>, but range-shaped inputs publish two related keys instead — reference whichever half a filter needs:Input block Publishes input,select$input.<name>daterange(mode:range)$input.<name>_from,$input.<name>_todaterange(mode:single)$input.<name>slider(range: false)$input.<name>slider(range: true)$input.<name>_min,$input.<name>_maxswitch$input.<name>(publishes a value when on; clears the condition entirely when off)rating(mode:input)$input.<name>A range daterange bound to
name: "created"is consumed as{ "createdAt": { "between": ["$input.created_from", "$input.created_to"] } }; a range slider bound toname: "price"as{ "price": { "gte": "$input.price_min", "lte": "$input.price_max" } }.
Export & Import
Move apps between instances with page bundles:
# Export selected pages (or all) as a JSON bundle
GET /pages/export?pages=<id,id,...|all>
# Import — dry-run first to get a validation report
POST /pages/import?dryRun=true { "bundle": { ... } }
# Then import for real, resolving any slug conflicts
POST /pages/import { "bundle": { ... }, "resolutions": { "<slug>": "skip" | "overwrite" | { "rename": "<new-slug>" } } }A bundle contains the pages, their blocks, and the collections they require. The dry-run report flags missing collections, missing fields, slug conflicts (with suggested slugs), unknown roles, and per-block validation errors — so you resolve everything before writing. New pages import disabled by default; roles are re-resolved by name on the target instance. In the admin app this is exposed as Import / export pages in the Pages sidebar.
Build with AI (MCP)
Because pages and blocks are plain config, AI assistants can build them for you. The
MCP server exposes dedicated page-builder tools —
baasix_create_page, baasix_create_block, baasix_update_block,
baasix_validate_block_config, and more — plus a live block-config reference resource
(baasix://docs/block-config) so the assistant generates valid configs from your schema.
Ask Claude or Copilot to "build an orders dashboard with a table of open orders and a doughnut chart of order status" and get a working, validated page.
Best Practices
- Start from the data. Pick the collection first, then choose the block that fits the view (table for lists, kanban for status flows, chart for aggregates).
- Keep pages focused. A page is a workspace for a task, not a dump of every block.
- Lean on filters. Use a filter block to drive several sibling blocks instead of hardcoding the same filter into each.
- Use role homepages. Set
options.homeForso each role lands on its own page after login. - Stage with
enabled: false. Build and review a page disabled, then enable it once it is ready. - Validate before bulk edits. When scripting or letting AI build pages, run
baasix_validate_block_config(or rely on the server-side validation) to catch bad field references early.