| title | Schema Rendering |
|---|
Object UI's schema rendering system is the core mechanism that transforms JSON configurations into live React components. This guide explains how it works and how to use it effectively.
The schema rendering engine follows a simple principle:
JSON Schema → SchemaRenderer → React Components → Beautiful UI
Every visual element in Object UI starts as a JSON object that describes what should be rendered, not how it should be rendered.
The SchemaRenderer is the primary component that interprets your JSON schemas:
import { SchemaRenderer } from '@object-ui/react'
import { initializeComponents } from '@object-ui/components'
// Side-effect import: loading the package runs its own field registration.
import '@object-ui/fields'
// Register components once at app initialization
initializeComponents()
function App() {
const schema = {
type: "page",
title: "My Dashboard",
body: { type: "text", content: "Hello" }
}
return <SchemaRenderer schema={schema} />
}Every schema object must have at minimum a type field:
import type { CSSProperties } from 'react'
interface BaseSchema {
type: string // Component type identifier
id?: string // Optional unique identifier
className?: string // Tailwind CSS classes
style?: CSSProperties // Inline styles (use sparingly)
visibleOn?: string // Expression for conditional visibility
hiddenOn?: string // Expression for conditional hiding
disabledOn?: string // Expression for conditional disabling
}{
"type": "card",
"id": "stats-card",
"className": "p-6 shadow-lg",
"title": "User Statistics",
"visibleOn": "${user.role === 'admin'}",
"body": {
"type": "text",
"content": "Total Users: ${stats.totalUsers}"
}
}The SchemaRenderer accepts a data prop that provides context for expressions:
const data = {
user: { name: "John", role: "admin" },
stats: { totalUsers: 1234 }
}
<SchemaRenderer schema={schema} data={data} />Use expression syntax ${} to reference data:
{
"type": "text",
"content": "Welcome, ${user.name}!"
}The schema renderer uses a component registry to map schema types to React components:
import { ComponentRegistry } from '@object-ui/core'
// `ComponentRegistry` is a process-level singleton — import it, do not construct one.
// Register a custom component
ComponentRegistry.register('my-component', MyComponent)
// Now you can use it in schemas
const schema = {
type: "my-component",
// ... component props
}Schemas can be nested to create complex UIs:
{
"type": "page",
"title": "Dashboard",
"body": {
"type": "grid",
"columns": 2,
"items": [
{
"type": "card",
"title": "Card 1",
"body": {
"type": "text",
"content": "Nested content"
}
},
{
"type": "card",
"title": "Card 2",
"body": {
"type": "chart",
"chartType": "bar",
"xAxisKey": "month",
"series": [{ "name": "revenue" }],
"data": [
{ "month": "Jan", "revenue": 120 },
{ "month": "Feb", "revenue": 80 }
]
}
}
]
}
}The chart's rows are written out on the node, and that is the spelling that plots. A
${…} on node-level data is not one of the rows SchemaRenderer evaluates: the raw
string reaches ChartRenderer, whose Array.isArray(schema.data) ? schema.data : [] drops
it, and the chart frame draws with nothing in it — no error, no warning. Since objectui#7113
that node is also refused by name, ChartSchema.data being z.array(z.record(…)). series
is required alongside it: series[].name picks the column to plot within each row, and
xAxisKey names the category column. chart reads no bind either, so resolve the array in
your own code and hand SchemaRenderer a schema whose rows are already on the node.
Use arrays for multiple items:
{
"type": "container",
"body": [
{ "type": "text", "content": "First item" },
{ "type": "text", "content": "Second item" },
{ "type": "text", "content": "Third item" }
]
}Object UI includes a powerful expression system for dynamic behavior:
{
"type": "text",
"content": "${user.firstName} ${user.lastName}"
}{
"type": "card",
"title": "${status === 'active' ? 'Active' : 'Inactive'}",
"description": "${status === 'active' ? 'This record is in use.' : 'This record is archived.'}"
}card here rather than badge, because an expression is evaluated only on a key the
node's own type carries. expressionBindableTextKeysFor — the lookup SchemaRenderer
consumes out of @objectstack/spec — gives card the rows title and description,
and gives badge no rows at all, so a ${…} written on a badge reaches the DOM as the
characters you typed. Resolve a badge's text before you hand the schema over, and author
it on label: text is not a BadgeSchema key.
{
"type": "button",
"label": "Delete",
"visibleOn": "${user.role === 'admin'}"
}{
"type": "alert",
"variant": "default",
"title": "Welcome!",
"body": {
"type": "text",
"content": "${
user.isNew ? 'Start with the quick tour.' :
user.tasks.length === 0 ? 'You are all caught up.' :
'You have tasks waiting.'
}"
}
}The branch sits on the nested text node's content, which SchemaRenderer evaluates
on every node type — the escape hatch for a component that carries no expression rows of
its own, and alert is one of those. Its severity could not be chosen by expression in
any case: AlertSchema.variant is the closed set default | destructive, so info,
warning and success are not values it accepts. Pick the variant in the host and
author it as a literal.
Components can emit events that you handle in React:
<SchemaRenderer
schema={schema}
onAction={(action, context) => {
console.log('Action:', action)
console.log('Context:', context)
}}
onSubmit={(data) => {
console.log('Form submitted:', data)
}}
/>Reference actions in schemas:
{
"type": "action:button",
"name": "call_api",
"label": "Click Me",
"actionType": "api",
"endpoint": "/api/action",
"method": "POST"
}Three things about the shape this replaces. A declarative action is its own NODE TYPE,
action:button — a plain button has no authorable handler: ButtonSchema.onClick is a
runtime slot for a host-supplied function, refused by name by the zod mirror, and (being
on SDUI_DOM_PASS_THROUGH_KEYS) forwarded straight to the DOM listener slot, where React
throws on the first click: "Expected onClick listener to be a function, instead got a
value of object type." The execution type is actionType, and the built-in vocabulary
is script | url | modal | flow | api | form (plus objectui's navigation
alias) — anything else must be a handler your host registered on ActionProvider. ajax
is neither. And the endpoint key is endpoint, with method; api is not a key any
action renderer forwards.
Large schemas are automatically optimized:
{
"type": "tabs",
"lazyLoad": true,
"tabs": [
{ "title": "Tab 1", "body": { /* Loaded when tab is clicked */ } },
{ "title": "Tab 2", "body": { /* Loaded when tab is clicked */ } }
]
}The renderer automatically memoizes components to prevent unnecessary re-renders.
Use dynamic imports for heavy components:
import { lazy } from 'react'
const HeavyChart = lazy(() => import('./HeavyChart'))
registry.register('heavy-chart', HeavyChart)The renderer includes built-in error boundaries:
<SchemaRenderer
schema={schema}
onError={(error, errorInfo) => {
console.error('Rendering error:', error)
// Log to error tracking service
}}
/>Full type safety for your schemas:
import type { PageNodeSchema, FormSchema } from '@object-ui/types'
const form: FormSchema = {
type: "form",
// TypeScript will validate this entire structure
fields: []
}
const schema: PageNodeSchema = {
type: "page",
title: "Typed Page",
body: [form]
}Break complex UIs into smaller, reusable schemas:
// ❌ Bad: One massive schema
const massiveSchema = { /* 500 lines of JSON */ }
// ✅ Good: Composed schemas
const headerSchema = { /* ... */ }
const contentSchema = { /* ... */ }
const footerSchema = { /* ... */ }
const pageSchema = {
type: "page",
body: [headerSchema, contentSchema, footerSchema]
}Pass all necessary data upfront:
// ✅ Good
const data = {
user: userData,
settings: userSettings,
stats: dashboardStats
}
<SchemaRenderer schema={schema} data={data} />Move logic to expressions instead of creating conditional schemas:
// ❌ Bad
const schema = user.isAdmin ? adminSchema : userSchema
// ✅ Good
const schema = {
type: "page",
body: [
{
type: "admin-panel",
visibleOn: "${user.isAdmin}"
},
{
type: "user-panel",
visibleOn: "${!user.isAdmin}"
}
]
}Always type your schemas for better IDE support and fewer runtime errors.
{
"type": "container",
"body": {
"type": "spinner",
"visibleOn": "${loading}"
}
}{
"type": "empty",
"visibleOn": "${items.length === 0}",
"message": "No items found",
"action": {
"type": "button",
"label": "Create New"
}
}{
"type": "alert",
"variant": "destructive",
"visibleOn": "${error}",
"title": "Something went wrong",
"body": { "type": "text", "content": "${error.message}" }
}visibleOn is a condition key and is evaluated on every node type. The message text is a
nested text node because alert carries no expression rows — and message is not an
AlertSchema key at all: the alert's own text keys are title and description, and the
renderer falls back from description to body. destructive is the variant this state
wants; error is not in the closed set.
- Component Registry - Learn about component registration
- Expression System - Master expressions
- Schema Overview - Explore all available schemas
- SchemaRenderer - Technical reference for the renderer
- Architecture Overview - System architecture
@object-ui/coreREADME - Core package API reference@object-ui/reactREADME - React package API reference