Page metadata — build custom app screens
Build custom pages with component-based layouts, variables, and event handling
A Page defines a custom UI layout using components, regions, and variables. Unlike Views which are bound to a single Object, Pages are flexible containers that can combine multiple components, embed views, and manage local state.
Basic Structure
const homePage = {
name: 'sales_home',
label: 'Sales Home',
type: 'home',
regions: [
{
name: 'header',
width: 'full',
components: [
{
type: 'metric_card',
id: 'total_revenue',
label: 'Total Revenue',
properties: {
object: 'opportunity',
field: 'amount',
aggregate: 'sum',
format: 'currency',
},
},
],
},
{
name: 'main',
width: 'large',
components: [
{
type: 'list_view',
id: 'recent_deals',
label: 'Recent Deals',
properties: {
object: 'opportunity',
view: 'recent_open',
limit: 10,
},
},
],
},
],
};Page Properties
| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Machine name (snake_case) |
label | string | ✅ | Display label |
description | string | optional | Page description |
type | enum | optional | Page type (see below; default 'record') |
object | string | optional | Associated object (for record type) |
template | string | optional | Layout template name (default: 'default') |
kind | enum | optional | Page override mode (default 'full'). full | slotted = structured authoring; html = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html); react = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via OS_PAGE_REACT=off. See Authoring modes |
regions | PageRegion[] | optional | Layout regions with components (default [] — list pages render via interfaceConfig, and an empty record/home/app page falls back to the synthesized default layout) |
source | string | optional | Page source text — required (and authoritative over regions) when kind is 'html' or 'react'. For kind: 'html' it is constrained JSX/HTML compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute). For kind: 'react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier). See React Pages |
variables | PageVariable[] | optional | Local state variables |
isDefault | boolean | optional | The default record page of its object — the page that object's record view opens. At most one isDefault: true record page per object per deployment: a second is refused by defineStack / os validate, at package registration and at metadata save, naming both pages. Saving a page under the existing default's own name is that page, not a second default. Outside the rule on a page that is not a record page bound to an object |
Page Types
| Type | Description | Use Case |
|---|---|---|
record | Tied to a specific object record | Custom detail pages |
home | Landing/home page | App entry points |
app | General application page | Custom layouts |
utility | Utility/helper panel | Tools, settings, wizards |
list | Record list/interface surface | Data-driven interface pages |
Earlier roadmap types (dashboard, form, record_detail, record_review, overview, blank) were removed from the schema because they never shipped a renderer (ADR-0049 enforce-or-remove); only the five types above are valid.
Authoring modes
A page's kind selects how the body is authored. Three modes ship, and the rest of
this page describes the first one:
kind | You write | Executed? | Reach for it when |
|---|---|---|---|
full (default) / slotted | Structured regions / slots of components — the rest of this page | — | Record, home and app layouts assembled from the component catalogue |
html | A source string of constrained JSX: registered components plus safe native HTML | No — parsed into the same component tree at save time by @objectstack/sdui-parser | Free-form layout, landing pages and composed dashboards, including author- or AI-generated ones you have not reviewed |
react | A source string of real React — hooks, handlers, arbitrary JS | Yes — in the app's own React tree, no sandbox | Interactive business UIs — master/detail, wizards, state-driven filters — written by authors you trust |
The two source tiers set source instead of regions. source is authoritative over
regions: when both are present the source wins, and regions holds at most a derived
cache of it. A page whose kind is html or react with no non-empty source
is rejected by the schema rather than rendering empty.
'jsx', the tier's first name, was retired in @objectstack/spec 18: an authored
kind: 'jsx' is refused with the prescription naming 'html'. Stored pages written as
'jsx' keep loading as html pages.
Because kind: 'react' executes author JavaScript, it is gated by a host capability that
defaults ON and is disabled server-side per deployment with OS_PAGE_REACT=off. The
html tier is unaffected by that switch — it is never executed.
Neither source tier is styled with Tailwind utility classes. A page's source is
runtime metadata, and the console's build-time Tailwind scans only the console's own
source — so a utility class name in page source silently produces no CSS, with no error
(ADR-0065). Style an html page with its components' structured props and a JSON style
object; style a react page with inline style={{ … }} and hsl(var(--token)) theme
colors.
Both tiers are checked at author time — block props, field bindings and source syntax —
by os validate, os lint and os build alike. See
React Pages for the full authoring guide and
Validating metadata for the rules.
Regions
Regions define layout zones on the page. Each region contains components.
regions: [
{
name: 'sidebar',
width: 'small',
components: [/* ... */],
},
{
name: 'content',
width: 'large',
components: [/* ... */],
},
]| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Region identifier |
width | enum | optional | 'small', 'medium', 'large', 'full' |
components | PageComponent[] | ✅ | Components in this region |
Components
Components are the building blocks placed inside regions.
{
type: 'chart',
id: 'revenue_chart',
label: 'Revenue Trend',
properties: {
chartType: 'line',
object: 'opportunity',
categoryField: 'close_date',
valueField: 'amount',
},
events: {
onClick: "navigate_to('opportunity_detail', { id: $event.id })",
},
visibleWhen: "'sales_manager' in current_user.positions",
style: { height: '400px' },
}| Property | Type | Required | Description |
|---|---|---|---|
type | string | ✅ | Component type (standard PageComponentType enum or custom string) |
id | string | optional | Unique component instance identifier |
label | string | optional | Display label |
properties | Record<string, unknown> | optional | Component-specific configuration (default {} — many components carry no props) |
events | Record<string, string> | optional | Event handlers (action expressions) |
style | object | optional | CSS styles |
className | string | optional | CSS class names |
responsiveStyles | object | optional | Preferred SDUI styling channel (ADR-0065): desktop-first per-breakpoint scoped style maps, compiled to id-scoped CSS at render. Keys are large (the unconditional base), then medium / small / xsmall as max-width overrides. Prefer design tokens, e.g. { large: { padding: 'var(--space-8)' }, small: { padding: 'var(--space-4)' } }. Use over ad-hoc style / className for metadata-authored pages. |
visibleWhen | string | optional | Visibility predicate (CEL) — component renders only when the expression is true. Binds record, current_user, and page state as page.<var>. (Legacy alias visibility is accepted but deprecated per ADR-0089.) |
The type field is a union of the standard PageComponentType enum and any custom string. The standard (namespaced) component types include:
- Structure:
page:header,page:footer,page:sidebar,page:tabs,page:accordion,page:card,page:section - Record context:
record:details,record:highlights,record:related_list,record:activity,record:chatter,record:path,record:alert,record:quick_actions,record:reference_rail,record:history— each renders from the record context a record page mounts, so they belong on atype:'record'page. Akind:'react'page mounts no such context andos validaterejects them there (see Validating metadata §10b) - Navigation:
app:launcher,nav:menu,nav:breadcrumb - Utility:
global:search,global:notifications—user:profileis not author-placeable: it is shell chrome (the signed-in user's avatar menu, which the app shell renders itself on every page), no renderer exists for it by ruling (objectstack#14159 / objectui#7135), and an authoreduser:profilenode is refused at the schema door —definePage(),os validate,os build— with that prescription at the node's path instead of drawing an unknown-type panel in front of a user - AI:
ai:suggestion—ai:chat_windowwas retired in v17.x: AI chat is not a page element, and the floating chat overlay the console mounts on every page is the supported entry point (set the app'sdefaultAgentto choose which platform agent answers there). No renderer for it ever shipped, and an authoredai:chat_windownode is refused at the schema door —definePage(),os validate,os build— with that prescription at the node's path instead of drawing an unknown-type panel in front of a user - Elements:
element:text,element:number,element:image,element:divider,element:button,element:record_picker,element:text_input(element:filterandelement:formwere retired in v17.x — no renderer ever shipped for either, and an authored node of either type is refused at the schema door —definePage(),os validate,os build— with that prescription at the node's path, bare node included. List surfaces own their filtering via a view'suserFiltersquick-filter bar or the list toolbar's filter builder; for forms use the object-boundobject-formblock, which is rendered and designer-publishable)
Components may also carry dataSource (a per-element data binding — see below), responsiveStyles (per-breakpoint scoped CSS, ADR-0065), and aria configuration. Custom string types are also accepted for project-specific widgets. (The former responsive layout block was retired in v17.x — no renderer ever applied it; see the upgrade guide.)
Element data sources
A component's dataSource takes one of two shapes. The object binding queries an object, overriding the page's own object, so one page can show several objects. The API binding reads one endpoint on the deployment's own origin and binds its answer, so a page can compose from a summary that is not an object:
import type { PageComponent } from '@objectstack/spec/ui';
// Object binding: `object` is required; `view`, `filter`, `sort` and `limit` narrow it.
const openDeals: PageComponent = {
type: 'list-view',
dataSource: {
object: 'opportunity',
view: 'open_deals',
filter: [{ field: 'amount', operator: 'greater_than', value: 10000 }],
limit: 10,
},
};
// API binding: `read.url` is a same-origin API path; `path` selects the part
// of the `{ success, data }` response the element binds.
const environments: PageComponent = {
type: 'stat',
dataSource: {
provider: 'api',
read: { url: '/api/v1/cloud/home' },
path: 'data.environments',
},
};The API binding's rules:
- Same-origin only.
read.urlmust open with a single/. An absolute URL of any scheme (https:,mailto:,javascript:, …), a protocol-relative//host, a backslash the browser reads as a slash, a space or control character, and a relative path such as./homeare all refused when the page is parsed (definePage(),os validate,os build), with the reason. - No credentials in metadata. The console reads the endpoint with its own signed-in fetch, so the read block has no
headers, and a credential key such asheaders,credentialsortokenis refused. - Reads only.
read.methodis optional and acceptsGETalone; there is nowriteblock. - Not rendered yet. The console's generic blocks that read an API binding (a stat, a usage bar, a checklist) have not shipped: until they do, the binding parses and no block reads it.
Variables
Pages can define local variables for managing state across components.
variables: [
{
name: 'selected_tab',
type: 'string',
defaultValue: 'overview',
},
{
name: 'date_range',
type: 'object',
defaultValue: { start: null, end: null },
},
]Variable type accepts 'string' (default), 'number', 'boolean', 'object', 'array', or 'record_id'.
Complete Example
const accountRecordPage = {
name: 'account_detail',
label: 'Account Detail',
type: 'record',
object: 'account',
variables: [
{ name: 'active_tab', type: 'string', defaultValue: 'details' },
],
regions: [
{
name: 'header',
width: 'full',
components: [
{
type: 'record:highlights',
id: 'header',
properties: {
fields: ['name', 'type', 'industry', 'owner'],
},
},
// Record actions are their own component — `record:highlights` renders
// field chips and nothing else.
{
type: 'record:quick_actions',
properties: {
actionNames: ['edit', 'delete', 'clone'],
},
},
],
},
{
name: 'sidebar',
width: 'small',
components: [
{
type: 'record:details',
id: 'key_fields',
label: 'Key Fields',
properties: {
fields: ['annual_revenue', 'employees', 'website', 'phone'],
},
},
{
type: 'record:activity',
id: 'activities',
label: 'Activity',
},
],
},
{
name: 'main',
width: 'large',
components: [
{
type: 'record:related_list',
id: 'contacts',
label: 'Contacts',
// `objectName` is the RELATED (child) object being listed;
// `relationshipField` is the child's field pointing back at this record.
properties: { objectName: 'contact', relationshipField: 'account' },
},
{
type: 'record:related_list',
id: 'opportunities',
label: 'Opportunities',
properties: { objectName: 'opportunity', relationshipField: 'account' },
},
],
},
],
};Related
- View Metadata — List views and form views for record display
- Dashboard Metadata — Analytics-focused page layout
- App Metadata — Organize pages into applications
- React Pages — choosing between the
htmlandreactsource-authoring tiers, and thereacttier's authoring guide in full - Validating metadata — Every author-time rule a page is held to