| title | Architecture Overview |
|---|---|
| description | Understanding ObjectUI's architecture, design principles, and how the pieces fit together |
ObjectUI is a universal, server-driven UI (SDUI) engine built on React, Tailwind CSS, and Shadcn UI. This guide explains the core architecture and how all the pieces work together.
ObjectUI follows three fundamental principles:
- JSON-First: Every UI element is described as JSON metadata, not hardcoded React components
- Backend Agnostic: Works with any backend system (ObjectStack, custom APIs, etc.)
- Component Library Quality: Combines low-code speed with Shadcn/Tailwind design quality
┌─────────────────────────────────────────────┐
│ JSON Schema (Protocol) │ ← Backend sends this
├─────────────────────────────────────────────┤
│ @object-ui/react (Renderer) │ ← Interprets schema
├─────────────────────────────────────────────┤
│ Component Registry + Field Registry │ ← Lookup system
├─────────────────────────────────────────────┤
│ @object-ui/components (UI Primitives) │ ← Buttons, Cards, etc.
│ @object-ui/fields (Form Inputs) │ ← Text, Date, Select
│ @object-ui/layout (Page Structure) │ ← AppShell, Sidebar
│ @object-ui/plugin-* (Advanced Widgets) │ ← Grid, Charts, Kanban
├─────────────────────────────────────────────┤
│ Shadcn UI + Radix UI (Primitives) │ ← Accessible components
│ Tailwind CSS (Styling) │ ← Utility-first CSS
└─────────────────────────────────────────────┘
ObjectUI is organized as a PNPM monorepo with clear separation of concerns:
- Role: The Protocol
- Contains: Pure TypeScript interfaces for JSON schemas
- Constraint: ZERO dependencies, no React code
- Example:
BaseSchema,ActionSchema,FieldSchema
- Role: The Engine
- Contains: Schema validation, expression evaluation, registries
- Constraint: No UI library dependencies, logic only
- Features:
- Expression engine (
visible: "${record.age > 18}") - Schema registry and validation
- Event system
- Expression engine (
- Role: The Runtime
- Contains:
SchemaRendererand React integration - Purpose: Transforms JSON schemas into live React components
- Role: The Atoms
- Contains: Shadcn primitives (Button, Badge, Card, Dialog, etc.)
- Constraint: Pure UI, no business logic
- Style: Tailwind CSS with
class-variance-authority
- Role: The Inputs
- Contains: Standard field renderers (Text, Number, Select, Date, etc.)
- Implements:
FieldWidgetPropsinterface - Purpose: Reusable form inputs with consistent API
- Role: The Shell
- Contains: Page structure components (AppShell, Page, Sidebar, Header)
- Purpose: Routing-aware composition and app scaffolding
Each plugin provides specialized, complex widgets:
@object-ui/plugin-grid- Data tables with ObjectStack integration@object-ui/plugin-kanban- Kanban board view@object-ui/plugin-charts- Recharts-based visualizations@object-ui/plugin-calendar- Calendar and event views@object-ui/plugin-map- Map visualization@object-ui/plugin-form- Advanced forms@object-ui/plugin-editor- Code editor (Monaco)@object-ui/plugin-markdown- Markdown renderer@object-ui/plugin-gantt- Gantt chart timeline@object-ui/plugin-timeline- Event timeline@object-ui/plugin-dashboard- Dashboard layouts@object-ui/plugin-chatbot- Chat interface
Important: Heavy dependencies (like Monaco, Recharts) are only allowed in plugin packages to keep the core bundle small.
Development tools and integration utilities:
@object-ui/cli- Command-line tool for building apps from schemas@object-ui/create-plugin- Interactive plugin scaffolder@object-ui/runner- Universal runtime for testing and demos@object-ui/data-objectstack- ObjectStack data backend adaptervscode-extension- VS Code extension for schema development
A backend system sends a JSON schema:
{
"type": "card",
"title": "Welcome",
"children": {
"type": "text",
"content": "Hello, ${user.name}!"
}
}The SchemaRenderer component:
- Receives the schema, and reads the expression scope off the context above it
- Evaluates expressions (
${user.name}) - Looks up the component type in the registry
- Recursively renders child schemas
- Handles events and state updates
The scope does not arrive as a prop. SchemaRenderer declares exactly one prop, schema,
and forwards everything else it is handed to the component the schema names — so a data={…}
written on the element is neither read nor refused. The host publishes its values with
PredicateScopeProvider, and every key it publishes becomes a root the expressions can read:
import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
import type { DeclaredNode } from '@object-ui/types'
// The schema from step 1, as the object the renderer receives.
const schema: DeclaredNode = {
type: 'card',
title: 'Welcome',
children: {
type: 'text',
content: 'Hello, ${user.name}!',
},
}
function App() {
const scope = { user: { name: 'Alice' } }
return (
<PredicateScopeProvider scope={scope}>
<SchemaRenderer schema={schema} />
</PredicateScopeProvider>
)
}An app built on @object-ui/app-shell does not mount this provider itself: the shell's
ExpressionProvider already feeds the same channel with the signed-in user (also readable as
current_user) and features. On top of what the host published, the renderer supplies
record — the row a record surface is bound to — and page, the page-local variables.
⛔ SchemaRendererProvider's dataSource is not an expression root. It carries the host's
DataSource adapter, the object data renderers call find() on; the two are different
channels on purpose.
The registry maps type strings to React components:
// During app initialization
ComponentRegistry.register('card', CardComponent)
ComponentRegistry.register('text', TextComponent)
// At runtime
const Component = ComponentRegistry.get('card') // → CardComponentThe registered component renders with evaluated props:
<CardComponent title="Welcome">
<TextComponent content="Hello, Alice!" />
</CardComponent>ObjectUI uses two registry systems for extensibility:
Maps schema types to React components:
import { ComponentRegistry } from '@object-ui/core'
// Register a component
ComponentRegistry.register('my-widget', MyWidgetComponent, {
label: 'My Widget',
category: 'Custom',
icon: 'box',
inputs: [
{ name: 'title', type: 'string' }
]
})Maps field types to input components:
import { registerFieldRenderer } from '@object-ui/fields'
// Register a field renderer
registerFieldRenderer('rating', RatingFieldComponent)This allows:
- ✅ Overriding standard components
- ✅ Adding custom field types
- ✅ Plugin system for complex widgets
- ✅ Keeping bundles small (lazy loading)
ObjectUI includes a powerful expression engine for dynamic UIs:
{
"type": "text",
"content": "Welcome, ${user.firstName} ${user.lastName}!"
}{
"type": "button",
"label": "Submit",
"visible": "${current_user.role === 'admin'}",
"disabled": "${record.status === 'locked'}"
}current_user is the signed-in user the host's ExpressionProvider publishes; record is the
row a record surface is bound to, and is the only spelling a row field has — the bare shorthand
(status) and the wrong-layer data.status were both retired on runtime record surfaces
(objectui#5330 phase 2). A head name outside the scope is not refused: the predicate is
unevaluable, this surface fails soft, and the node is shown on every row.
A button's text key is label, and text is not a ButtonSchema key at all. Nothing
refuses the misspelling either: BaseSchema is .passthrough(), so the validator KEEPS
the unknown key, and the renderer — which reads schema.label — never looks at it.
Measured on the node above with text: the button renders with an empty textContent,
so it appears on screen as a blank rectangle with no text.
{
"type": "statistic",
"label": "Orders",
"value": "${orders.length}",
"description": "${orders.length > 10 ? 'Above target' : 'On track'}"
}statistic rather than badge: an expression is evaluated only on a key the node's own
type carries, and expressionBindableTextKeysFor gives statistic the rows label,
value and description while giving badge none. A badge's text is its label, and it
has to arrive already resolved.
See the Expressions Guide for complete details.
Backend API
↓
JSON Schema + Data
↓
SchemaRenderer (evaluates expressions)
↓
Component Registry (maps types)
↓
React Components (render UI)
↓
User Interactions (events)
↓
Event Handlers (update data)
↓
Re-render (React state updates)
ObjectUI uses Tailwind CSS exclusively for styling:
All component variants use cva for type-safe variants:
import { cva } from 'class-variance-authority'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
destructive: 'bg-destructive text-destructive-foreground',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
}
}
}
)Use cn() helper (tailwind-merge + clsx) for class overrides:
import { cn } from '@/lib/utils'
<Button className={cn('default-classes', props.className)} />- Inline styles (
style={{}}) - except for dynamic values - CSS Modules
- Styled-components
- Any CSS-in-JS library
ObjectUI is built with TypeScript in strict mode:
import type { BaseSchema, ButtonSchema } from '@object-ui/types'
function handleClick() {
// ...
}
const schema: ButtonSchema = {
type: 'button',
label: 'Click me',
variant: 'default', // ✅ Type-checked
onClick: handleClick, // ✅ a handler, not its name — onClick is () => void | Promise<void>
}- Unit Tests: Vitest + React Testing Library
- Type Tests: TypeScript strict mode
Heavy dependencies only go in plugins:
- ✅
@object-ui/plugin-chartscan import Recharts - ❌
@object-ui/componentscannot import Recharts
Don't import components directly - use registries:
// ❌ Bad
import { MyGrid } from './MyGrid'
<MyGrid data={data} />
// ✅ Good
ComponentRegistry.register('my-grid', MyGrid)
{ type: 'my-grid', data: [...] }Never use inline styles or CSS-in-JS:
// ❌ Bad
<div style={{ backgroundColor: 'red' }}>
// ✅ Good
<div className="bg-red-500">Use expressions for dynamic content:
// ❌ Bad - hardcoded
{ type: 'text', content: 'Hello, John!' }
// ✅ Good - dynamic
{ type: 'text', content: 'Hello, ${user.name}!' }When creating a plugin:
- Create package in
packages/plugin-{name}/ - Export components and types
- Register components with ComponentRegistry
- Add documentation in
content/docs/plugins/ - Add to plugins meta.json
// packages/plugin-mywidget/src/index.tsx
import { ComponentRegistry } from '@object-ui/core'
import { MyWidget } from './MyWidget'
export { MyWidget }
ComponentRegistry.register('my-widget', MyWidget, {
label: 'My Widget',
category: 'Plugins',
icon: 'box'
})- Schema Rendering - How schemas become React components
- Component Registry - Registering custom components
- Field Registry - Custom field types
- Expressions - Dynamic expressions
- Plugins - Plugin system
- Data Sources - Data integration
- Utilities - Development tools and CLI utilities