| title | Custom Plugin Development |
|---|
This guide walks you through creating custom ObjectUI plugins — from scaffolding to publishing. Plugins extend ObjectUI with new view types, field widgets, or complex interactive components while keeping your application bundle lean through lazy loading.
A plugin is a self-contained package that registers one or more components into the Component Registry. When a JSON schema references a plugin's component type, the renderer resolves and renders it automatically.
Plugins differ from regular components in two ways:
- Lazy-loaded — heavy dependencies are code-split and fetched on demand.
- Self-registering — importing the package is enough; no manual wiring required.
Official plugins (@object-ui/plugin-grid, @object-ui/plugin-kanban, @object-ui/plugin-charts, etc.) all follow this pattern, and your custom plugins should too.
Every plugin has three key parts:
packages/plugin-board/
├── src/
│ ├── index.tsx # Entry point: lazy wrapper + ComponentRegistry.register()
│ ├── BoardImpl.tsx # Heavy implementation (imported lazily)
│ ├── BoardImpl.test.tsx # Tests
│ └── types.ts # TypeScript interfaces & schema types
├── package.json
├── vite.config.ts
├── tsconfig.json
└── README.md
| File | Role |
|---|---|
index.tsx |
Lightweight entry — sets up React.lazy(), Suspense fallback, and calls ComponentRegistry.register(). |
BoardImpl.tsx |
The actual renderer. All heavy dependencies live here so they are tree-shaken from the initial bundle. |
types.ts |
Schema interfaces extending BaseSchema from @object-ui/types. |
The fastest way to start is the create-plugin generator:
npx @object-ui/create-plugin board --description "Kanban-style board view"
# Or with pnpm / npm create aliases:
pnpm create @object-ui/plugin board
npm create @object-ui/plugin boardThis produces a ready-to-build plugin under packages/plugin-board/ with the correct package.json, Vite config, test file, and registry call already in place.
After scaffolding, install dependencies:
pnpm installLet's build a board view plugin that renders items in columns (similar to a Kanban but simplified).
// src/types.ts
import type { BaseSchema } from '@object-ui/types';
export interface BoardColumn {
id: string;
title: string;
}
export interface BoardItem {
id: string;
columnId: string;
title: string;
description?: string;
}
export interface BoardSchema extends BaseSchema {
type: 'board';
columns: BoardColumn[];
items: BoardItem[];
onItemMove?: (itemId: string, toColumnId: string) => void;
}
export interface BoardProps {
schema: BoardSchema;
className?: string;
}// src/BoardImpl.tsx
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
import { cn } from '@object-ui/components';
import type { BoardProps } from './types';
export default function BoardImpl({ schema, className }: BoardProps) {
const { columns, items } = schema;
return (
<div className={cn('grid gap-4', className)} style={{ gridTemplateColumns: `repeat(${columns.length}, 1fr)` }}>
{columns.map((col) => (
<div key={col.id} className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">{col.title}</h3>
{items
.filter((item) => item.columnId === col.id)
.map((item) => (
<Card key={item.id}>
<CardHeader className="p-3">
<CardTitle className="text-sm">{item.title}</CardTitle>
</CardHeader>
{item.description && (
<CardContent className="p-3 pt-0 text-xs text-muted-foreground">
{item.description}
</CardContent>
)}
</Card>
))}
</div>
))}
</div>
);
}// src/index.tsx
import React, { Suspense } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { Skeleton } from '@object-ui/components';
const LazyBoard = React.lazy(() => import('./BoardImpl'));
export const BoardRenderer: React.FC<{ schema: any; [key: string]: any }> = ({
schema,
...props
}) => (
<Suspense fallback={<Skeleton className="w-full h-[300px]" />}>
<LazyBoard schema={schema} {...props} />
</Suspense>
);
// Auto-register on import
ComponentRegistry.register('board', BoardRenderer, {
namespace: 'plugin-board',
label: 'Board View',
category: 'plugin',
inputs: [
{ name: 'columns', type: 'array', label: 'Columns', required: true },
{ name: 'items', type: 'array', label: 'Items', required: true },
],
defaultProps: {
columns: [
{ id: 'todo', title: 'To Do' },
{ id: 'done', title: 'Done' },
],
items: [],
},
});
export { default as BoardImpl } from './BoardImpl';
export type { BoardSchema, BoardProps, BoardColumn, BoardItem } from './types';Now any schema with "type": "board" will resolve to your component.
Field widgets follow the FieldWidgetComponentProps interface from @object-ui/fields.
// FieldWidgetComponentProps<T> shape (from packages/fields/src/widgets/types.ts)
type FieldWidgetComponentProps<T = any> = {
value: T;
onChange: (val: T) => void;
field: FieldMetadata;
readonly?: boolean;
disabled?: boolean;
className?: string;
error?: string;
};The validation slot is named error, matching FieldWidgetPropsSchema in
@objectstack/spec/ui — the published contract a widget is written against.
The form renderer supplies it from the active validation message.
Before v17 a widget could receive its metadata under either field or
schema, depending on which host rendered it, so widgets written in that era
resolve their config as field || schema. schema has been removed from
FieldWidgetComponentProps in v17: read props.field, full stop. Reading
props.schema now yields undefined.
schema itself is not going anywhere — it is the universal SDUI node
SchemaRenderer hands to every registered component (element:*, page:*,
grids, reports). That is precisely why a field widget needs an adapter when
it is rendered from a schema node instead of from a form. Wrap it once, at
registration, and the widget only ever implements one contract:
import { ComponentRegistry } from '@object-ui/core';
import { withFieldCarrier } from '@object-ui/fields';
ComponentRegistry.register('color', withFieldCarrier(ColorPickerField), {
namespace: 'field',
});withFieldCarrier forwards the node by reference (nothing is copied or
dropped) and consumes schema so it never reaches the DOM. Every built-in
field widget is registered through it.
The widget and the form renderer split validation display, and the split is not optional:
| Concern | Owner |
|---|---|
aria-invalid on the input |
the widget — only it renders the input element |
the required marker (*) |
the form renderer (<FormLabel>) |
| the message TEXT | the form renderer (<FormMessage/>) |
So consume error as a boolean signal — aria-invalid={!!error} — and do
not render the message yourself. The form already prints it below the control;
a widget that prints it too shows the user the same sentence twice. For the
same reason required is not in the props: the marker has one author.
// src/ColorPickerField.tsx
import React from 'react';
import { Input } from '@object-ui/components';
import type { FieldWidgetComponentProps } from '@object-ui/fields';
export function ColorPickerField({
value,
onChange,
field,
readonly,
disabled,
error,
}: FieldWidgetComponentProps<string>) {
if (readonly) {
return (
<div className="flex items-center gap-2">
<span
className="inline-block h-4 w-4 rounded-full border"
style={{ backgroundColor: value || '#000' }}
/>
<span className="text-sm">{value || '—'}</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
<input
type="color"
value={value || '#000000'}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="h-8 w-8 cursor-pointer rounded border-0 p-0"
/>
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field?.placeholder || '#000000'}
disabled={disabled}
className="font-mono text-sm"
// The whole job of `error` here: tell assistive tech the field failed.
// The message text is rendered by the form, not by this widget.
aria-invalid={!!error}
/>
</div>
);
}Register it as a field widget:
// src/index.tsx
import { ComponentRegistry } from '@object-ui/core';
import { withFieldCarrier } from '@object-ui/fields';
import { ColorPickerField } from './ColorPickerField';
ComponentRegistry.register('field-color', withFieldCarrier(ColorPickerField), {
namespace: 'plugin-board',
label: 'Color Picker',
category: 'field',
inputs: [
{ name: 'value', type: 'string', label: 'Value' },
{ name: 'placeholder', type: 'string', label: 'Placeholder' },
],
});
export { ColorPickerField };Namespaces prevent type collisions between plugins:
import { ComponentRegistry } from '@object-ui/core';
// Register with a namespace — accessible as 'plugin-board:board' AND 'board'
ComponentRegistry.register('board', BoardRenderer, {
namespace: 'plugin-board',
});
// Explicit lookup by namespace
ComponentRegistry.get('board', 'plugin-board');
// Fallback lookup (works when the type is unambiguous)
ComponentRegistry.get('board');Use skipFallback: true in the metadata if you do not want the component to be available without a namespace prefix.
ComponentRegistry.has('board'); // boolean
ComponentRegistry.getAllTypes(); // string[]
ComponentRegistry.getNamespaceComponents('plugin-board'); // ComponentConfig[]Define your schema interface in types.ts and extend BaseSchema:
import type { BaseSchema } from '@object-ui/types';
export interface BoardSchema extends BaseSchema {
type: 'board';
columns: BoardColumn[];
items: BoardItem[];
}Declare ComponentInput entries when registering so the visual designer can offer a property panel:
ComponentRegistry.register('board', BoardRenderer, {
inputs: [
{ name: 'columns', type: 'array', label: 'Columns', required: true },
{ name: 'items', type: 'array', label: 'Items', required: true },
{
name: 'layout',
type: 'enum',
label: 'Layout',
enum: ['horizontal', 'vertical'],
defaultValue: 'horizontal',
},
],
});ObjectUI uses Vitest + React Testing Library. Place tests next to the implementation.
// src/BoardImpl.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import BoardImpl from './BoardImpl';
const schema = {
type: 'board' as const,
columns: [
{ id: 'todo', title: 'To Do' },
{ id: 'done', title: 'Done' },
],
items: [
{ id: '1', columnId: 'todo', title: 'Write tests' },
{ id: '2', columnId: 'done', title: 'Ship plugin' },
],
};
describe('BoardImpl', () => {
it('renders all columns', () => {
render(<BoardImpl schema={schema} />);
expect(screen.getByText('To Do')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
});
it('renders items in correct columns', () => {
render(<BoardImpl schema={schema} />);
expect(screen.getByText('Write tests')).toBeInTheDocument();
expect(screen.getByText('Ship plugin')).toBeInTheDocument();
});
it('handles empty items gracefully', () => {
render(<BoardImpl schema={{ ...schema, items: [] }} />);
expect(screen.getByText('To Do')).toBeInTheDocument();
});
});Run tests:
pnpm vitest run packages/plugin-boardBefore publishing, verify:
-
package.jsonhas correctname,version,exports, andpeerDependencies. -
reactandreact-domare peer dependencies, not direct dependencies. -
@object-ui/coreand@object-ui/componentsare indevDependencies(orpeerDependencies). -
vite.config.tsmarks React and ObjectUI packages as external. - Types are exported via
"types"field inpackage.json. - All tests pass (
pnpm vitest run). - The entry point is lightweight — heavy code lives in
*Impl.tsxfiles.
pnpm build --filter @object-ui/plugin-board
ls -lh packages/plugin-board/dist/The entry chunk should be under 1 KB; the lazy chunk carries the bulk.
cd packages/plugin-board
npm publish --access publicpnpm add @object-ui/plugin-board// app/main.tsx — import once, auto-registers
import '@object-ui/plugin-board';{
"type": "board",
"columns": [
{ "id": "todo", "title": "To Do" },
{ "id": "done", "title": "Done" }
],
"items": [
{ "id": "1", "columnId": "todo", "title": "Write docs" }
]
}- Component Registry — registry internals and advanced usage
- Plugins Overview — official plugin catalog
- Schema Rendering — how schemas become UI
- Fields Guide — built-in field widgets and
FieldWidgetComponentProps