ObjectStackObjectStack

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

PropertyTypeRequiredDescription
namestring✅Machine name (snake_case)
labelstring✅Display label
descriptionstringoptionalPage description
typeenumoptionalPage type (see below; default 'record')
objectstringoptionalAssociated object (for record type)
templatestringoptionalLayout template name (default: 'default')
kindenumoptionalPage 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
regionsPageRegion[]optionalLayout regions with components (default [] — list pages render via interfaceConfig, and an empty record/home/app page falls back to the synthesized default layout)
sourcestringoptionalPage 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
variablesPageVariable[]optionalLocal state variables
isDefaultbooleanoptionalThe 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

TypeDescriptionUse Case
recordTied to a specific object recordCustom detail pages
homeLanding/home pageApp entry points
appGeneral application pageCustom layouts
utilityUtility/helper panelTools, settings, wizards
listRecord list/interface surfaceData-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:

kindYou writeExecuted?Reach for it when
full (default) / slottedStructured regions / slots of components — the rest of this page—Record, home and app layouts assembled from the component catalogue
htmlA source string of constrained JSX: registered components plus safe native HTMLNo — parsed into the same component tree at save time by @objectstack/sdui-parserFree-form layout, landing pages and composed dashboards, including author- or AI-generated ones you have not reviewed
reactA source string of real React — hooks, handlers, arbitrary JSYes — in the app's own React tree, no sandboxInteractive 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: [/* ... */],
  },
]
PropertyTypeRequiredDescription
namestring✅Region identifier
widthenumoptional'small', 'medium', 'large', 'full'
componentsPageComponent[]✅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' },
}
PropertyTypeRequiredDescription
typestring✅Component type (standard PageComponentType enum or custom string)
idstringoptionalUnique component instance identifier
labelstringoptionalDisplay label
propertiesRecord<string, unknown>optionalComponent-specific configuration (default {} — many components carry no props)
eventsRecord<string, string>optionalEvent handlers (action expressions)
styleobjectoptionalCSS styles
classNamestringoptionalCSS class names
responsiveStylesobjectoptionalPreferred 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.
visibleWhenstringoptionalVisibility 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 a type:'record' page. A kind:'react' page mounts no such context and os validate rejects them there (see Validating metadata §10b)
  • Navigation: app:launcher, nav:menu, nav:breadcrumb
  • Utility: global:search, global:notifications — user:profile is 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 authored user:profile node 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_window was 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's defaultAgent to choose which platform agent answers there). No renderer for it ever shipped, and an authored ai:chat_window node 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:filter and element:form were 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's userFilters quick-filter bar or the list toolbar's filter builder; for forms use the object-bound object-form block, 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.url must 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 ./home are 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 as headers, credentials or token is refused.
  • Reads only. read.method is optional and accepts GET alone; there is no write block.
  • 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' },
        },
      ],
    },
  ],
};

On this page