Quick Start
Get up and running with ObjectUI in 5 minutes - install, configure, and render your first server-driven UI
Get up and running with ObjectUI in a small Vite app. This guide installs the core renderer, registers the built-in component packages, and renders a first JSON schema.
Prerequisites
- Node.js and pnpm (or npm/yarn) — ObjectUI is tested on Node 22.x with pnpm 10.x.
- Basic knowledge of React and TypeScript
Step 1: Create a React Project
If you don't have an existing React project, create one with Vite:
pnpm create vite my-app --template react-ts
cd my-appStep 2: Install ObjectUI
Install the core ObjectUI packages:
pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fieldsInstall Tailwind CSS for styling:
pnpm add -D tailwindcss @tailwindcss/viteStep 3: Configure Tailwind CSS
Add Tailwind to your vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});Add to your src/index.css:
@import "tailwindcss";
@import "@object-ui/components/style.css";
@import "@object-ui/fields/style.css";Each style.css is a stylesheet the package compiles from its own sources at build time, and between them they carry every utility ObjectUI renders with — the themed ones (bg-primary, border-input) included.
Import them in that order. @object-ui/components/style.css is the complete sheet: Tailwind's base layer, the @theme tokens and the utilities its components use. @object-ui/fields/style.css is a small supplement on top of it — only the ~155 utilities the field widgets add and the components sheet does not already carry, which is why it is a few kB rather than another 170. It is not a standalone stylesheet, and on its own it will not style anything.
Plugin packages that publish a stylesheet need one line each. @object-ui/plugin-grid and @object-ui/plugin-kanban ship the same kind of supplement, built the same way, so add whichever of them you install:
@import "@object-ui/plugin-grid/style.css";
@import "@object-ui/plugin-kanban/style.css";Without that line the plugin renders with no themed styling at all — its bg-muted/10, bg-card/60 and text-muted-foreground/60 have no other source in a published app, because the @theme block they resolve lives in package source that is never published (#4929). The remaining @object-ui/plugin-* packages ship no stylesheet yet; importing one that does not exist breaks the build, so add only the lines above.
That is the whole styling setup: you do not add @source lines for the ObjectUI packages, and pointing Tailwind at them inside node_modules only regenerates utilities these imports already gave you.
Step 4: Render Your First Schema
Replace src/App.tsx with:
import '@object-ui/components';
import '@object-ui/fields';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import type { CardSchema } from '@object-ui/types';
const schema: CardSchema = {
type: 'card',
title: 'Team Directory',
description: 'Rendered from JSON metadata',
className: 'mx-auto max-w-3xl',
children: {
type: 'data-table',
caption: 'Users',
columns: [
{ header: 'Name', accessorKey: 'name', sortable: true },
{ header: 'Email', accessorKey: 'email' },
{ header: 'Department', accessorKey: 'department' },
],
data: [
{ name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering' },
{ name: 'Grace Hopper', email: 'grace@example.com', department: 'Marketing' },
{ name: 'Katherine Johnson', email: 'katherine@example.com', department: 'Sales' },
],
pagination: false,
searchable: false,
},
} as const;
function App() {
return (
<div className="min-h-screen bg-background p-8 text-foreground">
<SchemaRendererProvider dataSource={undefined}>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>
</div>
);
}
export default App;Importing @object-ui/components and @object-ui/fields registers their renderers with the shared ComponentRegistry. SchemaRendererProvider injects the host's data adapter, and everything below it — expressions, smart fields, data-aware plugins — reads it back from there.
This app renders inline data, so it has no adapter to inject and says so with undefined. That is a real state of the contract, not a placeholder: dataSource is typed DataSource | null | undefined (@object-ui/types), so a host either hands over an adapter or states that it has none. It used to be typed any and this example passed an empty object, which no renderer can do anything with (objectui#7912).
Step 5: Run the App
pnpm devOpen http://localhost:5173. You should see a card and data table rendered from JSON.
What Just Happened?
- Schema - the UI was described as JSON with
type, visual props, and nestedchildren. - Registry - importing the component packages registered renderers for
cardanddata-table. - Renderer -
SchemaRendererresolved eachtypeand rendered React components. - Provider -
SchemaRendererProvideris where a host injects itsDataSource; this app has none, so it passesundefined.
Expression Scope
A ${…} expression in a schema reads a root the host publishes. Publish those roots with
PredicateScopeProvider:
Basic Usage
import React from 'react'
import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
import type { DeclaredNode } from '@object-ui/types'
// Importing the package registers every default renderer as a side effect —
// there is no separate registration call.
import '@object-ui/components'
const schema: DeclaredNode = {
type: "page",
// An app page draws `title` as its heading; a page with no `pageType` is a
// record page, which leaves the heading to a `page:header` block.
pageType: "app",
title: "Dashboard",
children: {
type: "grid",
columns: 3,
children: [
{ type: "statistic", label: "Total Users", value: "${stats.users}" },
{ type: "statistic", label: "Revenue", value: "${stats.revenue}" },
{ type: "statistic", label: "Orders", value: "${stats.orders}" }
]
}
}
function App() {
// Every key of this object becomes a root the schema's expressions can read —
// `stats` here is what answers `${stats.users}`.
const scope = {
stats: { users: 1234, revenue: "$56,789", orders: 432 }
}
return (
<PredicateScopeProvider scope={scope}>
<SchemaRenderer schema={schema} />
</PredicateScopeProvider>
)
}
export default AppExpression scope reaches the renderer through the provider, never through a prop on the
element. SchemaRenderer declares exactly one prop, schema, and forwards every other prop
it is handed to the component the schema names — so a value passed as data={…} is neither
read nor refused, and the expression that wanted it is returned as its own source text, with
nothing thrown and one line on the console.
⛔ dataSource is the adapter — the object data renderers call find() on — and not an
expression root. It is a different channel from the expression scope: publish the values your
${…} expressions read with PredicateScopeProvider, and inject the adapter your data
components query with SchemaRendererProvider.
Copy-Paste Schemas
A ${name.…} in any of these reads the root name off the scope the host published — see
Expression Scope for the provider that publishes one. A head name nothing
published is not an error: the expression is returned as its own source text.
Contact Form
{
"type": "form",
"fields": [
{ "name": "name", "type": "text", "label": "Full Name", "required": true },
{ "name": "email", "type": "email", "label": "Email", "required": true },
{ "name": "subject", "type": "select", "label": "Subject", "options": [
{ "label": "General Inquiry", "value": "general" },
{ "label": "Bug Report", "value": "bug" },
{ "label": "Feature Request", "value": "feature" }
]},
{ "name": "message", "type": "textarea", "label": "Message", "required": true }
],
"submitLabel": "Send Message"
}Data Grid
{
"type": "object-grid",
"properties": {
"objectName": "user",
"title": "Users",
"columns": [
{ "field": "name", "label": "Name", "sortable": true },
{ "field": "email", "label": "Email" },
{ "field": "department", "label": "Department" },
{ "field": "status", "label": "Status" },
{ "field": "created_at", "label": "Joined" }
],
"showSearch": true,
"operations": { "create": true, "update": true, "delete": true, "export": true }
}
}Dashboard
A row of live KPI tiles. A dashboard node's widgets[] takes metric-card entries and
dataset-bound charts instead; see Dashboard Plugin.
{
"type": "grid",
"columns": 4,
"children": [
{ "type": "statistic", "label": "Revenue", "value": "${stats.revenue}", "trend": "up", "description": "+12%" },
{ "type": "statistic", "label": "Orders", "value": "${stats.orders}", "trend": "up", "description": "+8%" },
{ "type": "statistic", "label": "Customers", "value": "${stats.customers}", "trend": "up", "description": "+5%" },
{ "type": "statistic", "label": "Conversion", "value": "${stats.conversion}", "trend": "down", "description": "-2%" }
]
}Kanban Board
{
"type": "object-kanban",
"objectName": "tasks",
"groupBy": "status",
"cardTitle": "title",
"cardFields": ["assignee", "priority", "due_date"],
"columns": [
{ "id": "todo", "title": "To Do", "className": "border-t-2 border-indigo-500" },
{ "id": "in_progress", "title": "In Progress", "className": "border-t-2 border-amber-500" },
{ "id": "review", "title": "In Review", "className": "border-t-2 border-blue-500" },
{ "id": "done", "title": "Done", "className": "border-t-2 border-green-500" }
]
}More schemas: ui/examples/schema-catalog
is the canonical catalog — the JSON schemas the docs render, a smoke test mounts, and AI agents
use as a few-shot corpus.
Next Steps
Add Actions
Actions are data, not inline functions. Declare one as an action:button
node whose properties bag carries the block's props, as @objectstack/spec
declares them: actionType names the built-in executor the action runner
dispatches to, and the bag's other keys carry that executor's arguments —
target is the location a url action navigates to. Written flat on the node,
actionType and target are refused by the spec's strict page component:
{
"type": "action:button",
"properties": {
"label": "Open details",
"actionType": "url",
"target": "/users/ada"
}
}Learn the full action model in Enhanced Actions.
Connect a Data Source
pnpm add @object-ui/data-objectstackimport { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com'
});Pass the adapter to SchemaRendererProvider and let data-aware renderers call the DataSource interface. See Data Connectivity.
Learn More
- Architecture Overview — Understand how ObjectUI works
- Schema Rendering — Deep dive into schema rendering
- Component Registry — Customize and extend components
- Plugins — Add views like Grid, Kanban, Charts
- Fields Guide — Field widgets and cell renderers
Custom front-end
Three ways to build your own UI on ObjectStack: a React page inside the Console, your own front-end over the Client SDK, or your own React app rendering metadata with the ObjectUI SDK
Building a CRUD App
End-to-end tutorial for building a complete CRUD application with ObjectUI — from schema definition to deployment