Building a CRUD App
End-to-end tutorial for building a complete CRUD application with ObjectUI — from schema definition to deployment
Building a CRUD App
This tutorial walks you through building a Task Manager CRUD application with ObjectUI — from schema definition to data source setup to deployment.
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: Project Setup
Create a new React project and install the required ObjectUI packages:
pnpm create vite task-manager --template react-ts
cd task-managerInstall ObjectUI core packages and the plugins you need:
pnpm add @object-ui/react @object-ui/core @object-ui/types @object-ui/components @object-ui/fields
pnpm add @object-ui/plugin-grid @object-ui/plugin-form @object-ui/plugin-detailInstall Tailwind CSS:
pnpm add -D tailwindcss @tailwindcss/viteAdd 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";Step 2: Register Components
Create src/setup.ts to register the built-in component and field renderers:
import { initializeComponents } from '@object-ui/components';
// Side-effect imports: loading each package runs its own registration.
import '@object-ui/fields';
import '@object-ui/plugin-grid';
import '@object-ui/plugin-form';
import '@object-ui/plugin-detail';
initializeComponents();Loading each package registers what it owns — the components, the field widgets
and each plugin's blocks all go into the one ComponentRegistry that
@object-ui/core exports. initializeComponents() takes no arguments; it exists
so a bundler cannot tree-shake the side-effect import away.
Every block this tutorial renders comes from a plugin, so the three plugin
imports are not optional extras: object-grid lives in @object-ui/plugin-grid,
object-form in @object-ui/plugin-form and detail-view in
@object-ui/plugin-detail. Leave one out and SchemaRenderer has nothing to
resolve that type to — it renders the red Unknown component type panel
instead of the block.
Import this file once at the top of your app entry point (src/main.tsx or src/App.tsx).
Step 3: Define the Object Schema
Create src/schemas/task.ts. This is the metadata that drives the entire UI — grid columns, form fields, validation, and views are all derived from this schema:
import type { FieldMetadata } from '@object-ui/types';
const fields: Record<string, FieldMetadata> = {
title: { name: 'title', type: 'text', label: 'Title', required: true },
status: {
name: 'status',
type: 'select',
label: 'Status',
defaultValue: 'Todo',
options: [
{ label: 'Backlog', value: 'Backlog' },
{ label: 'Todo', value: 'Todo' },
{ label: 'In Progress', value: 'In Progress' },
{ label: 'Review', value: 'Review' },
{ label: 'Done', value: 'Done' },
],
},
priority: {
name: 'priority',
type: 'select',
label: 'Priority',
defaultValue: 'Medium',
options: [
{ label: 'Critical', value: 'Critical' },
{ label: 'High', value: 'High' },
{ label: 'Medium', value: 'Medium' },
{ label: 'Low', value: 'Low' },
],
},
assignee: { name: 'assignee', type: 'text', label: 'Assignee' },
due_date: { name: 'due_date', type: 'date', label: 'Due Date' },
description: { name: 'description', type: 'textarea', label: 'Description' },
};
export const TaskSchema = {
name: 'task',
label: 'Task',
icon: 'check-circle-2',
titleFormat: '{title}',
fields,
listViews: {
all: {
label: 'All Tasks',
columns: ['title', 'status', 'priority', 'assignee', 'due_date'],
},
active: {
label: 'Active',
columns: ['title', 'status', 'priority', 'assignee', 'due_date'],
filter: [['status', '!=', 'Done']],
sort: [{ field: 'priority', order: 'asc' }],
},
},
};An object's metadata is a plain document — the same shape a backend serves from
DataSource.getObjectSchema() — so it is written as a literal rather than built
by a helper. FieldMetadata is the union every field entry belongs to (text,
select, date, textarea, …), and typing the fields record against it is
what makes a wrong type or a misspelled key fail at compile time. ObjectUI
reads those entries to choose the renderer, apply validation, and generate form
controls automatically.
Step 4: Create a Data Source
ObjectUI never hardcodes fetch calls inside components. Instead, it communicates with your backend through the DataSource interface. Create src/data/rest-data-source.ts:
import type { DataSource, QueryParams, QueryResult } from '@object-ui/types';
export class RestDataSource implements DataSource {
constructor(private baseUrl: string) {}
async find(resource: string, params?: QueryParams): Promise<QueryResult> {
const query = new URLSearchParams();
if (params?.$top) query.set('$top', String(params.$top));
if (params?.$skip) query.set('$skip', String(params.$skip));
// `$orderby` is a union — an OData clause string, a map, or an array of
// fields. This backend speaks the string form, so narrow to it rather
// than stringifying a shape the server cannot parse.
if (typeof params?.$orderby === 'string') query.set('$orderby', params.$orderby);
if (params?.$search) query.set('$search', params.$search);
const res = await fetch(`${this.baseUrl}/${resource}?${query}`);
const data = await res.json();
return { data: data.items, total: data.total };
}
async findOne(resource: string, id: string | number) {
return (await fetch(`${this.baseUrl}/${resource}/${id}`)).json();
}
async create(resource: string, data: Partial<any>) {
const res = await fetch(`${this.baseUrl}/${resource}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
async update(resource: string, id: string | number, data: Partial<any>) {
const res = await fetch(`${this.baseUrl}/${resource}/${id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
}
async delete(resource: string, id: string | number) {
return (await fetch(`${this.baseUrl}/${resource}/${id}`, { method: 'DELETE' })).ok;
}
async getObjectSchema(objectName: string) {
return (await fetch(`${this.baseUrl}/schema/${objectName}`)).json();
}
}Tip: For the ObjectStack backend, use the official
@object-ui/data-objectstackadapter instead of writing your own. See the Data Connectivity guide for details.
Step 5: Render a CRUD Grid View
Wire everything together in src/App.tsx. SchemaRendererProvider injects the
data source once, and every SchemaRenderer beneath it renders its schema
against that one adapter:
import './setup';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { RestDataSource } from './data/rest-data-source';
const dataSource = new RestDataSource('https://api.example.com/v1');
function App() {
return (
<SchemaRendererProvider dataSource={dataSource}>
<div className="min-h-screen bg-background p-6">
<SchemaRenderer
schema={{
type: 'object-grid',
objectName: 'task',
}}
/>
</div>
</SchemaRendererProvider>
);
}
export default App;This renders a fully interactive data grid with sortable columns, pagination,
and row actions. object-grid fetches the object's schema itself — through
your data source's getObjectSchema (Step 4) — to generate the columns and
field types, so nothing from src/schemas/task.ts needs to be imported here;
that file only has to describe what your backend actually serves.
Two details in that snippet are load-bearing, and getting either wrong produces a grid that draws its header and nothing else:
- The data source is injected by an ancestor, not passed to the block.
object-grid,object-formanddetail-viewall read the adapter fromSchemaRendererProvider, so it has to be above them in the tree. AdataSourcewritten on the block itself reaches that one block and never becomes context for anything under it. - The object is named by
objectName. That is the key each of these blocks declares as required; a differently-spelled key (object) is not read, so the block has no object to query.
Neither mistake used to say anything — the block simply rendered empty. Both now report themselves: a block that resolves no adapter renders a No data source resolved panel naming itself and the object it was about to read.
Step 6: Add Create and Edit Forms
ObjectUI generates forms directly from your schema. Extend App.tsx with form state:
const [showForm, setShowForm] = useState(false);
const [editId, setEditId] = useState<string | null>(null);Add a "New Task" button and handle row clicks to open the edit form:
Both of these render inside the SchemaRendererProvider from Step 5, so neither
carries a data source of its own:
<SchemaRenderer
schema={{ type: 'object-grid', objectName: 'task' }}
onRowClick={(row: any) => { setEditId(row.id); setShowForm(true); }}
/>
{showForm && (
<SchemaRenderer
schema={{
type: 'object-form',
objectName: 'task',
mode: editId ? 'edit' : 'create',
recordId: editId,
}}
onSubmit={() => setShowForm(false)}
onCancel={() => setShowForm(false)}
/>
)}recordId is the key object-form reads to load the record it is editing — it
is the form's own spelling and is not shared with detail-view, which uses
resourceId (Step 8).
The form automatically renders the correct field widgets (text inputs, select dropdowns, date pickers) based on your FieldMetadata definitions. Validation rules like required are enforced out of the box.
In create mode the form also opens with the defaultValues your schema
declares — the status and priority fields above start on Todo and
Medium, already submittable, rather than empty next to a required marker.
Only static defaults are seeded: a defaultValue that is a runtime token
('NOW()', 'current_user') or a CEL expression is an instruction the server
resolves at insert time, so the form leaves that field empty and lets it. In
edit mode nothing is seeded — the form shows the record as stored. Values
you pass as initialData / initialValues outrank a schema default.
Step 7: Add Filters and Search
Leverage the active list view you defined in Step 3 by binding the grid to
it declaratively, with the spec's per-element dataSource binding
(dataSource: { object, view }). This is the same binding list-view,
detail-view and every other object-bound block in this repo read —
ElementDataSourceGate resolves view against the object's saved views
(fetched through your data source's getObjectSchema / listViews) and
composes that view's filter and sort onto the query for you:
const [activeView, setActiveView] = useState('all');
// View switcher buttons — the names match the `listViews` keys from Step 3.
<button onClick={() => setActiveView('all')}>All Tasks</button>
<button onClick={() => setActiveView('active')}>Active</button>
// The grid re-resolves `view` whenever `activeView` changes.
<SchemaRenderer
schema={{
type: 'object-grid',
dataSource: { object: 'task', view: activeView },
}}
/>Selecting Active re-queries with the active view's filter
(status != Done) and sort (priority asc) applied — you never assemble
$filter / $orderby by hand. A view name your backend does not publish is
reported, not silently ignored: swap activeView for a name outside
listViews and the grid renders a configuration-error panel in place of the
table, the same way an unresolved objectName does (Step 5). A page that
instead fell back to the object's full, unfiltered scope would look like it
worked while returning every record regardless of which button was pressed —
so this block does not offer that fallback.
Search needs no binding at all. object-grid renders its own search box
in the toolbar — on by default — and typing there drives
DataSource.find()'s $search parameter directly; there is no separate
query-param key to author. Add searchableFields: ['title', 'description']
to the schema to narrow which fields the server matches; leave it out and the
server decides.
Step 8: Add a Detail View
Create a detail page that renders a single record with all its fields:
import { SchemaRenderer } from '@object-ui/react';
function TaskDetail({ taskId, onBack }: { taskId: string; onBack: () => void }) {
return (
<div className="min-h-screen bg-background p-6">
<button onClick={onBack} className="mb-4 text-sm text-muted-foreground">
← Back to list
</button>
<SchemaRenderer
schema={{
type: 'detail-view',
objectName: 'task',
resourceId: taskId,
}}
/>
</div>
);
}Render TaskDetail inside the same SchemaRendererProvider as the grid — it
reads the injected data source from context, exactly as object-grid and
object-form do.
Two keys differ from the form above, and both matter:
resourceId, notrecordId.detail-viewsources the record id fromresourceId;recordIdisobject-form's spelling. The two blocks are not interchangeable here.- No
data. Ondetail-view,datameans "here is the record already, do not fetch" — so handing it anything (including the object's metadata) makes the block skipfindOneentirely and render that value as if it were the record. Omit it and the block loads the record for itself.
Use this component in your main app with simple routing state, or integrate with a router like React Router or TanStack Router for URL-based navigation.
Deployment Considerations
Environment config — Keep your API URL configurable:
const dataSource = new RestDataSource(
import.meta.env.VITE_API_URL || 'http://localhost:3000/api'
);Performance — Use server-side pagination via $top/$skip query params. ObjectUI plugins support lazy loading via LazyPluginLoader from @object-ui/react. Set cache: { enabled: true, ttl: 300 } on your schema for client-side caching.
Production build — Run pnpm build and deploy the dist/ folder to any static host (Vercel, Netlify, Cloudflare Pages).
Authentication — Extend RestDataSource to inject auth headers:
class AuthenticatedDataSource extends RestDataSource {
constructor(baseUrl: string, private getToken: () => string) {
super(baseUrl);
}
// Override fetch calls to include: Authorization: `Bearer ${this.getToken()}`
}Next Steps
- Explore the Schema Overview for advanced schema features
- Add a Kanban board view using
@object-ui/plugin-kanban(see the Kanban Plugin reference for runnable board schemas) - Connect to a production backend with the Data Connectivity guide
- Build multi-object apps with relationships using
lookup/master_detailfields (see the Lookup Field reference)