BaasixBaasix
Guides

App Builder Guide

← Back to Documentation Home

Table of Contents

  1. Overview
  2. Getting Help in the Editor
  3. Data Model
  4. The Grid
  5. Block Types
  6. Block Palette
  7. Add-Block Wizard
  8. Arranging Blocks
  9. Appearance
  10. Conditional Formatting
  11. Themes
  12. Layout & Nesting
  13. Building a Page
  14. Block Config
  15. Block Click Actions
  16. Cross-Block Reactivity
  17. Chart Drill-Down
  18. Expressions & Bind Picker
  19. Input Blocks: Types & Validation
  20. Date Range Kinds
  21. Upload Publishing
  22. Forms: Wizards, Conditions & Widgets
  23. Custom Forms & Workflows
  24. Custom Widgets
  25. Reports
  26. Permissions
  27. Public Pages
  28. Filters & Master-Detail
  29. Export & Import
  30. Build with AI (MCP)
  31. 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

FieldTypeNotes
nameStringDisplay name
slugStringUnique per tenant; used in the route ?slug=
iconStringLucide icon name for the menu
descriptionText (nullable)
parent_IdUUID (self-FK, null)Menu nesting
sortIntegerMenu ordering
isPublicBoolean (default false)Reachable without login via /p/?slug=
enabledBoolean (default true)Disabled pages are hidden from the menu and renderer
optionsJSONPage-level settings (e.g. menuGroup, homeFor, theme)
rolesJSON (nullable)Role ids allowed to see the page in menus (null/empty = all)

baasix_Block

FieldTypeNotes
page_IdUUID FKOwning page (onDelete: cascade)
typeENUMOne of the 53 block types (below)
collectionString (null)Bound collection (null for collectionless blocks)
positionJSON{ row, col, span } on the 12-column grid
parentBlock_IdUUID (self-FK, null)Parent container block (tabs/container/modal); null = top level
slotString (null)Slot within the parent: tab:<index> for tabs, body otherwise
configJSONPer-type configuration
configVersionIntegerFor 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 vertically
  • col 0–11 — starting column
  • span 1–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):

BlockWhat it shows
tableData grid: columns, filters, sort, inline edit, actions
formCreate/edit records — flat, or a multi-step wizard
detailsRead-only field view of a single record
kanbanDrag-to-update board grouped by a field
calendarRecords mapped to date fields (month/week/day); multi-day events that span the visible range boundary are fetched too
chart9 chart kinds with server-side aggregation
cardlistCard/grid layout with title, image, fields
mapMap with markers, clustering, popups — Leaflet or Google Maps
geochartChoropleth world map shaded by an aggregated value
feedRealtime message/notes thread with a composer
mediaImage/video/audio gallery from a file field
timelineVertical event timeline with day grouping, icons, colors
progressKPI vs target as a progress bar or radial gauge
repeaterA markdown template rendered once per record ({{field}})
reportThe reports endpoint (aggregate + groupBy) as a table with CSV export
filterPublishes filter state to sibling blocks

Input blocks (collectionless or optional — publish a value as $input.<name> for sibling blocks' filters, see Filters & Master-Detail):

BlockBinds to a collection?What it publishes
inputnoStandalone text/select/date/toggle input driving sibling blocks' filters ($input.<name>)
selectoptionalDropdown — static options or a collection's rows; $input.<name>
daterangenoDate or date-range picker with presets; $input.<name>_from / _to (single mode: $input.<name>)
slidernoNumeric slider, single or two-thumb range; $input.<name> (range mode: _min / _max)
switchnoToggle or segmented control; publishes a value, or clears the filter condition when toggled off
ratingoptionalStar 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):

BlockWhat it shows
statKPI tiles — one aggregate per tile, with an optional compare-filter delta and sparkline
treeHierarchy from a self-referencing collection; clicking a node publishes a selection
stepsStep/progress indicator driven by a bound record's status field
badgeA record field's value as a colored chip; or distinct field values with counts
countdownCountdown timer to a target date/time, or a live clock
avatarRow of user avatars from a collection's name/image fields
comparisonTwo aggregated metrics side by side with a computed delta
keyvalueA record's fields as a compact labeled-row grid
leaderboardTop-N ranked list of grouped records with a metric and bars

Navigation blocks (collectionless):

BlockWhat it does
breadcrumbsTrail of parent pages — automatic from page hierarchy, or manual
linksGrid or list of shortcut buttons using the block action system
headerHero heading with subtitle, description, icon, and action buttons

Layout blocks (collectionless):

BlockWhat it does
tabsTab strip; each tab hosts its own nested grid (lazy-mounted)
containerCard or plain section around a nested grid; optionally collapsible
modalHidden dialog hosting blocks — opened by an "open modal" action
dividerHorizontal rule with an optional label
spacerFixed-height vertical gap between blocks
subpageEmbeds 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:

BlockBinds to a collection?What it shows
markdownnoStatic markdown content
richtextoptionalRich text (lexical editor) — static, or a record's HTML field
buttonsnoLink / workflow / modal / page action buttons
iframenoEmbed an external URL
uploadnoFile upload widget
codeoptionalCode/JSON viewer (live record field or static)
videooptionalVideo or audio from a URL (YouTube/Vimeo/direct) or a file field
pdfoptionalInline PDF viewer from a URL or a file field
carouseloptionalImage slideshow from a static list or a file field, with optional autoplay
alertnoCallout banner with tone (info/success/warning/error), markdown body, optional dismiss
htmlnoStatic HTML rendered inside a sandboxed iframe
imageoptionalImage from a URL or a file field, with fit/aspect ratio and an optional click action
widgetoptionalSandboxed 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:

  1. Search & select — a full-sheet searchable palette of all available block types, grouped by category, with each block's icon, name, and description visible.
  2. 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:

SettingValuesNotes
Backgroundtheme token or hex color
Border colortheme token or hex color
Border width0–8px
Corner radius0–32px
Shadownone / sm / md / lg
Paddingnone / sm / md / lg
Hide card chromebooleanDrops the card background/border/shadow — content only
Title alignmentleft / center / right
Title sizesm / md / lg
Title colortheme token or hex color
Accent colortheme token or hex colorDrives 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:

BlockConfig keyEffect
tableformatting.columns.<field> (rule list per column)Text color / icon / bold on the matching cell
tableformatting.rows (flat rule list)Row background tint — row rules support only style.background
details, keyvalueformatting (flat rule list)Each rendered field is matched by rule.field against the whole record
cardlist, kanbanformatting (flat rule list)Accent-only: a matching rule paints a 3px left border (textColor, falling back to background) — no fill or text recoloring
statper-tile formattingMatched against { value }; overrides that tile's accent color (and bold/icon)
comparisonformatting (flat rule list)Matched against the left value only; overrides its color
leaderboardformatting (flat rule list)Matched per row against { value }; overrides that row's bar/label color
progressformatting (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:

FieldTypeNotes
nameString
tokensJSON{ light?: {...}, dark?: {...} } — one value per CSS token
isDefaultBooleanDefault theme for its scope
tenant_IdUUID (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-1chart-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:

  • tablecolumns[{ field, label?, format? }], filter?, sort?, pageSize?, actions{ create?, edit?, delete?, view? }, search?, inlineEdit?
  • kanbangroupByField, cardTitleField, cardFields?[], allowDrag?
  • calendarstartField, endField?, titleField, views?[]
  • chartchartType, aggregate{ alias: { function, field } }, groupBy?[], filter?
  • formmode: "create"|"edit", fields[{ field, required?, visibleWhen?, widget? }] or wizard steps[], successMessage?
  • tabstabs[{ label, icon? }]; modalwidth?; progressaggregate, target (number or a second aggregate), variant: "bar"|"radial"
  • mapgeometryField, provider?: "leaflet"|"google" (Google needs a Maps JS API key saved in project settings as metadata.googleMapsApiKey; without one it falls back to Leaflet)
  • subpageslug (the embedded page's slug; embeds one level deep only — a subpage that itself contains a subpage block does not recurse)
  • comparisonleftLabel, leftAggregate, leftFilter?, rightLabel, rightAggregate, rightFilter?, format?: "number"|"currency"|"percent", prefix?, suffix?
  • leaderboardgroupField, metric{ function, field }, filter?, limit? (3–25, default 10), labelPrefix?
  • avatarnameField, 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
  • badgemode: "record"|"distinct"; distinct groups all records by field on the server and renders one chip per distinct value with its count, instead of resolving a single bound record

Filter DSL — every filter value 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, avataritemAction in 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 action field (inside the tiles[N] array) for per-tile click behavior.
  • Map, feeditemAction runs 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:

NameValue
paramThe page URL's query params
inputPage input values, keyed by input block name
selectionSelected record by block id — selection.<blockId>.<field>
user{ id, firstName, lastName, fullName, email, roleName } (best-effort; null on public pages)
nowThe 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:

TypeWhat it acceptsValidation
textShort textminLength, maxLength, pattern (regex)
textareaLong text (multi-line)minLength, maxLength
numberInteger or decimal numbersmin, max, step
decimalDecimal numbers (alternate widget)min, max, step
emailEmail addressBuilt-in email format check
urlURLBuilt-in URL format check
passwordMasked password inputminLength, maxLength, pattern
telPhone numberNo built-in format; use pattern for custom rules
selectDropdown (static or collection-bound options)Optional required flag
dateCalendar date pickerNo additional validation
toggleBoolean switch / checkboxOptional 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>_from and $input.<name>_to.
  • time — Clock times only (HH:mm); picker shows a time input. Single mode publishes a time; range mode publishes _from and _to times.
  • datetime — Full date + time picker with both calendar and clock. Single mode publishes an instant; range mode publishes _from and _to instants.

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 with widgetOptions { 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:

  1. Create a workflow with an HTTP trigger (webhook) or use the execute route for logged-in pages
  2. In the form block, select Workflow as the submit target (instead of a collection)
  3. Define the form's fields inline (name, type, validation) — no collection binding needed
  4. 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:

MethodDoes
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.

  • $input composite 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 blockPublishes
    input, select$input.<name>
    daterange (mode: range)$input.<name>_from, $input.<name>_to
    daterange (mode: single)$input.<name>
    slider (range: false)$input.<name>
    slider (range: true)$input.<name>_min, $input.<name>_max
    switch$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 to name: "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.homeFor so 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.

On this page