ObjectUIObjectUI

React Pages

Author a page body as real React (kind:'react') or as constrained JSX that is parsed and never executed (kind:'html') — and how to choose between them.

React Pages

Most pages in ObjectUI are a schema treeregions[].components[] of JSON nodes. Two page kinds let you write the body as source instead, for layouts that are awkward to express as nested JSON:

kindSource isExecuted?Author trust
"html"Constrained JSX/HTMLNo — parsed into a schema treeUntrusted OK
"react"Real React (hooks, handlers, arbitrary JS)Yes — in the main React treeTrusted only

Both set source and leave regions unused. "jsx" is a deprecated alias for "html" and is still accepted.

Page kind also carries the record-page override values "full" (default) and "slotted" — a different axis, covered in Slotted Pages.

Choosing between them

Reach for kind:'html' by default. It is parsed, whitelisted against the public block manifest, and never executed, so it is safe for AI-generated and customer-authored pages. It covers layout, blocks, and styling — styling through the blocks' own structured props plus a JSON style object, not Tailwind (see Styling, below; the rule holds on both tiers).

Reach for kind:'react' only when you need real behaviour the schema tree cannot express — local state, computed lists, event handlers wiring one block to another, custom data fetching. It runs without a sandbox.

kind:'react'

{
  "type": "home",
  "name": "project_console",
  "kind": "react",
  "source": "function Page() {\n  const [selected, setSelected] = React.useState(null);\n  return (\n    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>\n      <ListView objectName=\"showcase_project\" fields={['name', 'status']} onRowClick={(r) => setSelected(r._id)} />\n      {selected && <ObjectForm objectName=\"showcase_project\" mode=\"edit\" recordId={selected} />}\n    </div>\n  );\n}"
}

Written out, that source is:

function Page() {
  const [selected, setSelected] = React.useState(null);
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
      <ListView
        objectName="showcase_project"
        fields={['name', 'status']}
        onRowClick={(r) => setSelected(r._id)}
      />
      {selected && <ObjectForm objectName="showcase_project" mode="edit" recordId={selected} />}
    </div>
  );
}

The security gate

A react page's source is transpiled and evaluated directly in the application — no isolation, full access to the page's React tree. The platform assumes page authors are reviewed and draft-gated, so the host capability react-pages defaults ON.

A deployment that does not trust its authors turns it off server-side with OS_PAGE_REACT=off (or disableCapability('react-pages') in the host). Pages then render an explanatory notice instead of executing. Existing kind:'html' pages are unaffected.

What is in scope

Nothing is imported. These identifiers are injected as closure variables:

In scopeWhat it is
ReactThe host's React — call hooks with it (React.useState).
The public data blocksEvery public non-container block, as a PascalCase tag on this tier — but what resolves and what you author against are two different sets, below.
BlockEscape hatch for anything not injected.
useAdapterThe live data source — query/create/update.
data, variables, pageThe page's own data, local variables, and schema.

Two tiers: what resolves, and what you author against

The runtime scope is every block in the curated public contract (PUBLIC_BLOCKS) that is not a layout container. On this tier tags are derived by splitting the registry type on -, _ and : and PascalCasing each part: object-grid<ObjectGrid>, record:details<RecordDetails>. A kind:'html' page writes the registry type itself instead — <object-grid>, <record:details>. Blocks registered lazily are in scope too — you never wait on a plugin chunk to reference one.

The authored contract is the much smaller set that has published props — checked by os validate and generated into the reference an author, human or AI, writes against: <ObjectForm>, <ListView>, <ObjectChart>, <Block>. That set is REACT_BLOCKS in @objectstack/spec, and the generated per-prop table is skills/objectstack-ui/references/react-blocks.md in the framework repo. Treat that table as the prop authority, not this page.

Everything in the runtime scope but outside the contract still resolves and renders — its props simply are not part of the react-tier contract. Reach those through the contract instead: a kanban / calendar / gantt / timeline / map of an object is <ListView viewType="kanban" …>, or <Block type="object-kanban" …>.

The record:* family is excluded from this tier

The tag derivation above is real — <RecordDetails> and <RecordHighlights> are defined in the scope — but every record:* block reads its record from the record context a record page mounts, and a kind:'react' page never mounts one. The block renders empty however you bind it: its objectName/recordId are not read by the renderer. os validate rejects them at publish time:

  ✗ Author-time rules failed (1 issue)
  • page "showcase_renewals_pipeline" › RecordHighlights: RecordHighlights renders
    "record:highlights", which reads its record from the record context a record page
    mounts — a kind:'react' page never mounts one, so the block renders empty no matter
    how it is bound (its objectName/recordId are not read by the renderer).
      rule: react-block-needs-record-context

The rule matches by type, so <Block type="record:details" /> is rejected the same way. On a react page, bind the record yourself:

Instead ofWrite
<RecordDetails><ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} /> — it binds by its own props.
<RecordHighlights><ObjectForm … mode="view" />, or read the record with useAdapter().findOne and lay the strip out in JSX.
<RecordRelatedList><ListView objectName="child_object" filters={['lookup_field', '=', parentId]} /> — the parent binding is an ordinary filter here.
<RecordPath>Read the record with useAdapter().findOne and render the stage bar in JSX.

If you want the whole record-page composition, author the page as type:'record' instead — that is the page kind that mounts the context these blocks render from.

Layout containers are deliberately not injected. The scope builder skips every container (if (!tag || cfg.isContainer) continue;), so <flex>, <grid>, <card> and friends have no injected wrapper. In react mode you compose layout with real HTML, which React is better at than a schema-children renderer — styled inline, not with Tailwind: <div style={{ display: 'flex', gap: 16 }}>.

Styling — page source is metadata, not build input

Do not author Tailwind utility classes in page source — on either tier. A page's source is runtime metadata. The console's Tailwind is compiled at build time by scanning the console's own src, and there is no safelist, so it never sees your page. A utility class in page source produces CSS only if that exact class happens to already appear in objectui's own source, and otherwise produces nothing, with no error anywhere.

This is the most expensive mistake on this tier: the page still renders — correct structure, correct data, no styling — and nothing reports it. It is recorded as a 2026-06-30 amendment to ADR-0080 under ADR-0065, after a modal's bg-black/50 backdrop rendered fully transparent in production. os validate reports it as page-source-className-tailwind (a warning, on both tiers).

Each tier has its own styling primitive:

kindStyle with
"react"Inline style={{ … }}, with hsl(var(--token)) for colour.
"html"The blocks' own structured props (<flex direction gap>, <grid columns>) plus a JSON style object.

Colours come from the active theme, so the page follows light/dark and whatever theme the deployment installs:

<div
  style={{
    background: 'hsl(var(--card))',
    border: '1px solid hsl(var(--border))',
    borderRadius: 'var(--radius)',
    padding: 12,
    color: 'hsl(var(--foreground))',
  }}
>

</div>

Common tokens: --background, --foreground, --card, --muted, --muted-foreground, --border, --primary, --destructive, plus the spacing/radius tokens --space-* and --radius.

For overlays, do not hand-roll a position: fixed; inset: 0 backdrop — render the form in its built-in Sheet or Dialog, which arrives already styled: <ObjectForm … formType="drawer" drawerSide="right" />.

Blocks take flat props

An injected block folds its JSX props into the block's schema, so you write flat props rather than a nested schema object:

<ListView objectName="showcase_project" fields={['name', 'status']} pagination={{ pageSize: 25 }} />

Use the canonical spelling of each prop — the one the contract publishes. Several blocks still read older flat spellings as back-compat fallbacks but do not declare them, so they are not authoring surface: on <ObjectGrid>, for instance, pageSize and fields are deprecated aliases of pagination and columns.

Function props (onRowClick, onSelect) are passed through as real callbacks — that is how you wire one block to another.

One collision to know about: type is both the schema's component discriminator and a legitimate prop name on some blocks (a chart's family, for instance). The discriminator wins the type slot, and your value is preserved next to it as specType for the block to read.

Block — the escape hatch

Any registered component, including ones outside the public contract:

<Block type="object-tree" objectName="showcase_category" />

Live data

function Page() {
  const adapter = useAdapter();
  const [rows, setRows] = React.useState([]);

  React.useEffect(() => {
    adapter
      .find('showcase_project', { $filter: ['status', '=', 'open'] })
      .then((res) => setRows(res.data ?? []));
  }, [adapter]);

  return <ul>{rows.map((r) => <li key={r._id}>{r.name}</li>)}</ul>;
}

Two things in that call are easy to get wrong, and neither one errors:

The $ prefixes are load-bearing. Every query key starts with $$select, $filter, $orderby, $skip, $top, $expand, $search, $searchFields, $count. An unprefixed filters: or top: is not a query option — the adapter reads only the $-prefixed keys, so anything else is dropped and the call comes back unfiltered, or with the default page size, with no error. $filter takes an ObjectQL filter array — ['field', 'op', value], with and/or compounds spelled ['and', [...], [...]].

find resolves to a QueryResult, not an array. It is { data, total, page, pageSize, hasMore }; the rows are res.data. Passing the result straight to setRows and then calling .map on it throws.

Source shapes

The page renders the source's default export. An implicit export default is added when the source starts with JSX, a function declaration, (), or class:

function Page() { return <p/>; }     // ✅
<p>hi</p>                            // ✅
() => <p>hi</p>                      // ✅

const Page = () => <p/>;             // ❌ exports nothing
const Page = () => <p/>;
export default Page;                 // ✅

The const Page = … form does not get the implicit export — export it explicitly. Getting this wrong reports an error in the page error panel; it does not silently render blank.

When something throws

Transpile errors, evaluation errors, and errors thrown while rendering all surface in a React page error panel with the message. The error is held until the page source or its data changes, so it does not flicker or escape to the generic renderer error.

Referencing an identifier that is not in scope is the common case, and reads as ReferenceError: <Name> is not defined — usually a layout container (not injected — use HTML) or a block outside the public contract (use Block).

Page state

A react page keeps its own useState across re-renders and across lazy plugin loads. Three things reset it, all intentional: a change to source, a change to the page's data/variables, and a new data source — the page is genuinely a different page then.

That last one is a requirement on the host, not the author. The page is recompiled when the adapter's identity changes, because recompiling is the only way the new adapter reaches the blocks inside the page. So a host that constructs a new adapter on every render resets every react page on every render. Provide it from state or a module constant:

// ❌ new adapter object every render — every react page below loses its state
<AdapterCtx.Provider value={new ObjectStackAdapter(config)}>

// ✅
const [adapter, setAdapter] = useState<ObjectStackAdapter | null>(null);
<AdapterCtx.Provider value={adapter}>

@object-ui/app-shell's AdapterProvider already does this correctly; the rule matters for custom hosts and preview surfaces.

kind:'html'

The constrained tier. Same JSX-looking syntax, but the source is parsed into a schema tree and rendered through the normal renderer — never executed. Only tags in the public block manifest are allowed, props are validated against each block's declared inputs, and unknown tags are a hard error at save time.

Those tags are the registered type names, written verbatim — whatever the registry spells, character for character, including a record: / page: / element: / action: namespace prefix and any underscore inside the name: <list-view>, <object-form>, <record:related_list>, <record:quick_actions>. The whitelist is an exact string comparison, so nothing is normalised for you: the PascalCase tags this page shows for kind:'react' are the other tier's convention and are not registered names (<ListView> is rejected with <ListView> is not an allowed component), and neither is a name re-spelled to look uniform — <record:related-list> is not registered, only <record:related_list> is.

Use it for anything author- or AI-generated. Expressions are limited to what the schema supports (${data.x}), and there is no local state or event handling beyond the action system.

Styling works the same way as on the react tier — page source is never scanned by the build — but with this tier's own primitive: lay out with the blocks' structured props (<flex direction gap>, <grid columns>) and add CSS as a JSON style object. See Styling, above.

On this page