Data Sources & Variable Binding¶
Overview¶
Every block on a page can have a data source that tells the runtime where to fetch data from. Data sources can also declare variable bindings — instructions for injecting runtime values (URL parameters, authenticated user info, parent record fields, or static defaults) into queries and filters.
Variable bindings are the mechanism that makes pages dynamic. They let you build patterns like list-to-detail navigation, user-scoped views, and pre-filtered dashboards without writing any code.
Data source types¶
A data source connects a block to your data. There are two types:
| Type | Description | Use case |
|---|---|---|
structure | Fetches records directly from a collection | Tables, record cards, forms, metrics |
query | Executes a saved query (smart query) | Complex joins, pre-built reports |
Structure data source¶
A structure data source points to a collection by ID and can specify fields, filters, sorting, and pagination:
{
"dataSource": {
"type": "structure",
"ref": "COLLECTION_ID",
"config": {
"fields": ["name", "email", "status", "createdAt"],
"sort": [{ "field": "createdAt", "direction": "desc" }],
"limit": 50
}
}
}
The mode field controls how data is fetched:
| Mode | Behavior |
|---|---|
list | Returns multiple records (default for tables) |
single | Returns one record by ID (default for detail pages) |
aggregate | Returns computed metrics (count, sum, avg, min, max) |
Query data source¶
A query data source executes a saved query by ID. Variables are passed as query parameters:
{
"dataSource": {
"type": "query",
"ref": "QUERY_ID",
"config": {},
"variables": {
"status": { "source": "static", "value": "active" }
}
}
}
When variables are resolved at runtime, they are passed to the query as parameters. The query definition determines how those parameters are used (e.g., as filter values, in WHERE clauses).
Variable binding¶
Variable bindings map named variables to runtime values. They are declared in the variables field of a data source:
{
"dataSource": {
"type": "structure",
"ref": "COLLECTION_ID",
"config": {},
"variables": {
"assignedTo": { "source": "auth", "field": "userId" },
"status": { "source": "static", "value": "open" }
}
}
}
At runtime, the Pages service resolves each variable binding against the current page context and injects the resulting values as filters (for structure sources) or parameters (for query sources).
The four variable sources¶
1. URL — source: "url"¶
Reads a value from the URL query string. Use this for navigation-driven filtering, such as passing a record ID from a list page to a detail page.
When the page is loaded at https://pages.centrali.io/acme/order-detail?id=abc-123, this binding resolves to "abc-123".
2. Auth — source: "auth"¶
Reads a value from the authenticated user's context. Use this for user-scoped views.
Available fields:
| Field | Description |
|---|---|
userId | The authenticated user's ID |
email | The authenticated user's email address |
name | The authenticated user's display name |
Requires authentication
Auth bindings only resolve when the page's access policy is set to authenticated or role-gated. On public pages, auth bindings will fail to resolve.
3. Record — source: "record"¶
Reads a value from the primary record on a detail page. Use this for related lists that should be scoped to the current record.
The field can be any field on the primary record: "id", "status", "customerId", or any custom data field.
Two-phase resolution
The runtime resolves data sources in two phases. In phase 1, it fetches all blocks that do not depend on record context (including the primary record). In phase 2, it resolves blocks with record bindings using the primary record from phase 1. This means record bindings work automatically on detail pages — no extra configuration needed.
4. Static — source: "static"¶
Provides a literal default value. Use this for pre-filtered views where the filter value is known at design time.
Variable precedence¶
When a data source has multiple variables, each is resolved independently according to its declared source. The four sources have a defined precedence order when evaluating fallback behavior:
| Priority | Source | Description |
|---|---|---|
| 1 (highest) | url | URL query parameters |
| 2 | record | Primary record context |
| 3 | auth | Authenticated user context |
| 4 (lowest) | static | Literal default value |
Note
Precedence applies within the variable resolver when determining which source to check first. Each variable binding declares exactly one source — precedence matters when you are reasoning about which runtime values are available and in what order they are evaluated.
Common patterns¶
List-to-detail navigation with scoped related lists¶
A common pattern: a list page shows all orders. Clicking a row navigates to a detail page that shows the order and its line items.
List page — orders (page type: list):
{
"sections": [
{
"id": "sec-1",
"kind": "content",
"title": "Orders",
"layout": "single-column",
"blocks": [
{
"id": "block-orders",
"blockType": "table",
"dataSource": {
"type": "structure",
"ref": "ORDERS_COLLECTION_ID",
"config": {
"fields": ["orderNumber", "customer", "total", "status"],
"sort": [{ "field": "createdAt", "direction": "desc" }]
}
},
"actions": [
{
"id": "action-view",
"type": "navigate-to-page",
"label": "View Order",
"targetRef": "order-detail",
"activation": "row-click",
"config": { "useQueryParams": true },
"paramConfig": {
"source": "row",
"mode": "selected",
"selectedFields": ["id"]
}
}
]
}
]
}
]
}
Detail page — order-detail (page type: detail):
{
"sections": [
{
"id": "sec-header",
"kind": "content",
"title": "Order Details",
"layout": "single-column",
"blocks": [
{
"id": "block-order",
"blockType": "record-card",
"dataSource": {
"type": "structure",
"ref": "ORDERS_COLLECTION_ID",
"mode": "single",
"config": {
"fields": ["orderNumber", "customer", "total", "status", "createdAt"]
}
}
}
]
},
{
"id": "sec-items",
"kind": "content",
"title": "Line Items",
"layout": "single-column",
"blocks": [
{
"id": "block-items",
"blockType": "related-list",
"dataSource": {
"type": "structure",
"ref": "LINE_ITEMS_COLLECTION_ID",
"mode": "list",
"config": {
"fields": ["product", "quantity", "unitPrice", "lineTotal"]
},
"variables": {
"data.orderId": { "source": "record", "field": "id" }
}
}
}
]
}
]
}
When the detail page loads with ?id=abc-123:
- Phase 1: The
block-orderrecord card fetches the order with IDabc-123. - Phase 2: The
block-itemsrelated list resolvesdata.orderIdfrom the order record'sidfield, then fetches line items wheredata.orderId = abc-123.
User-scoped views ("My Approvals")¶
Show only records assigned to the currently signed-in user:
{
"dataSource": {
"type": "structure",
"ref": "APPROVALS_COLLECTION_ID",
"mode": "list",
"config": {
"fields": ["title", "requestedBy", "amount", "status"],
"sort": [{ "field": "createdAt", "direction": "desc" }]
},
"variables": {
"data.assignedTo": { "source": "auth", "field": "userId" }
}
}
}
At runtime, data.assignedTo is resolved to the authenticated user's ID and applied as a filter. The user only sees approvals assigned to them.
Pre-filtered dashboard with static defaults¶
Show only active records on a dashboard metric:
{
"dataSource": {
"type": "structure",
"ref": "TICKETS_COLLECTION_ID",
"mode": "aggregate",
"config": {},
"aggregation": {
"operations": {
"openTickets": { "count": "*" }
}
},
"variables": {
"data.status": { "source": "static", "value": "open" }
}
}
}
The data.status variable is resolved to "open" and applied as a filter before the aggregation runs.
Smart query with URL parameter¶
Use a saved query that accepts a parameter from the URL:
{
"dataSource": {
"type": "query",
"ref": "QUERY_ID",
"config": {},
"variables": {
"customerId": { "source": "url", "param": "customerId" }
}
}
}
The query receives customerId as a parameter. The query definition determines how it uses that value (e.g., filtering by customer ID).
Error handling¶
When a variable cannot be resolved, the runtime returns an empty result set for that block instead of showing unfiltered data. This is a deliberate safety measure — a page should never accidentally display all records when a filter variable is missing.
The block's response includes a variableError field explaining what went wrong:
{
"data": [],
"meta": { "total": 0, "page": 1, "pageSize": 50 },
"variableError": "Missing required variable: data.orderId (source: record, field: id) — primary record not available"
}
Common resolution errors:
| Error | Cause | Fix |
|---|---|---|
| Missing URL param | URL does not contain the expected query parameter | Ensure the navigation action passes the required parameter |
| User not authenticated | Auth binding on a public page | Set the page access policy to authenticated or role-gated |
| Primary record not available | Record binding but no record ID in URL | Ensure the detail page receives a record ID via the id query parameter |
| Static value empty | Binding declared but value is empty string | Check the value field in the static binding |
Never unfiltered
If any variable in a data source fails to resolve, the entire data source returns empty. The runtime will not fall back to an unfiltered query. This prevents accidental data exposure.
Worked example: complete list-to-detail setup¶
This example walks through a complete setup: a Projects list page that navigates to a Project Detail page showing the project record and its related tasks.
Step 1: Create the list page¶
curl -X POST https://api.centrali.io/workspace/my-workspace/api/v1/pages \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Projects",
"slug": "projects",
"pageType": "list"
}'
Step 2: Save the list page definition¶
curl -X POST https://api.centrali.io/workspace/my-workspace/api/v1/pages/PAGE_ID/versions \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"definition": {
"sections": [
{
"id": "sec-projects",
"kind": "content",
"title": "Projects",
"layout": "single-column",
"blocks": [
{
"id": "block-projects",
"blockType": "table",
"dataSource": {
"type": "structure",
"ref": "PROJECTS_COLLECTION_ID",
"config": {
"fields": ["name", "owner", "status", "dueDate"],
"sort": [{ "field": "dueDate", "direction": "asc" }]
}
},
"actions": [
{
"id": "action-open",
"type": "navigate-to-page",
"label": "Open",
"targetRef": "project-detail",
"activation": "row-click",
"config": { "useQueryParams": true },
"paramConfig": {
"source": "row",
"mode": "selected",
"selectedFields": ["id"]
}
}
]
}
]
}
],
"theme": { "inherit": true }
}
}'
Step 3: Create the detail page¶
curl -X POST https://api.centrali.io/workspace/my-workspace/api/v1/pages \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Project Detail",
"slug": "project-detail",
"pageType": "detail"
}'
Step 4: Save the detail page definition with variable bindings¶
curl -X POST https://api.centrali.io/workspace/my-workspace/api/v1/pages/DETAIL_PAGE_ID/versions \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"definition": {
"sections": [
{
"id": "sec-project",
"kind": "content",
"title": "Project",
"layout": "single-column",
"blocks": [
{
"id": "block-project",
"blockType": "record-card",
"dataSource": {
"type": "structure",
"ref": "PROJECTS_COLLECTION_ID",
"mode": "single",
"config": {
"fields": ["name", "owner", "status", "dueDate", "description"]
}
}
}
]
},
{
"id": "sec-tasks",
"kind": "content",
"title": "Tasks",
"layout": "single-column",
"blocks": [
{
"id": "block-tasks",
"blockType": "related-list",
"dataSource": {
"type": "query",
"ref": "TASKS_BY_PROJECT_QUERY_ID",
"config": {},
"variables": {
"projectId": { "source": "record", "field": "id" }
}
}
}
]
}
],
"theme": { "inherit": true }
}
}'
Step 5: Publish both pages¶
# Publish the list page
curl -X POST https://api.centrali.io/workspace/my-workspace/api/v1/pages/LIST_PAGE_ID/publish \
-H "Authorization: Bearer YOUR_TOKEN"
# Publish the detail page
curl -X POST https://api.centrali.io/workspace/my-workspace/api/v1/pages/DETAIL_PAGE_ID/publish \
-H "Authorization: Bearer YOUR_TOKEN"
How it works at runtime¶
- User visits
https://pages.centrali.io/my-workspace/projects - The list page shows all projects in a table
- User clicks a row — the
navigate-to-pageaction redirects tohttps://pages.centrali.io/my-workspace/project-detail?id=PROJECT_RECORD_ID - The detail page resolves:
- Phase 1: Fetches the project record using the
idfrom the URL - Phase 2: Passes the project's
idinto theprojectIdvariable of the tasks query, returning only tasks for that project
- Phase 1: Fetches the project record using the
- The user sees the project details and its related tasks
Related documentation¶
- Pages Overview — Page types, sections, blocks, and access control
- Actions — Navigation, record operations, and trigger invocation
- Queries — Creating saved queries that accept parameters