Admin UI
Registering admin screens with AdminSDK — routes, slots, published components.
The admin is a single React SPA. A plugin ships an ES module bundle that the host imports
dynamically and which calls back into window.AdminSDK.
Why a global, not an import#
A plugin bundle must not contain React. Two React copies on one page produce broken hooks, duplicated context, and errors that point nowhere near the cause.
The host owns React, the router, the store and the component library, and exposes them on
window.AdminSDK. A plugin reaches for them there:
const { React, ui, components, hooks } = window.AdminSDK;
The build config enforces it — see Building the bundle.
Registering#
ui/index.tsx is the entry point. It runs on dynamic import and registers everything the
plugin contributes:
const SDK = window.AdminSDK;
SDK.registerPlugin({
key: 'article',
routes: [
{ path: '/posts', element: ArticleList },
{ path: '/posts/:id/edit', element: ArticleEdit },
],
});
interface PluginRegistration {
key: string;
routes?: { path: string; element: React.ComponentType }[];
slots?: { slot: string; element: React.ComponentType; order?: number }[];
components?: Record<string, React.ComponentType>;
}
Paths are relative to the admin prefix: /posts resolves to /admin/posts, or whatever the
prefix is configured to. Never hard-code /admin.
Routes registered here must also be declared server-side in adminRoutes() so global
conflict detection can see them — see Writing a plugin.
Slots#
A slot is a named insertion point. Plugins fill slots published by the host or by other plugins:
SDK.registerPlugin({
key: 'seo',
slots: [
{ slot: 'plugin:article:detail.tabs', element: SeoTab, order: 20 },
],
});
| Slot owner | Naming |
|---|---|
| Host | header.right, sidebar.footer, dashboard.widgets |
| Plugin | plugin:{key}:{slot} |
Publish your own insertion point with PluginSlot:
const { PluginSlot } = SDK.components;
<PluginSlot name="plugin:article:detail.tabs" />
Every plugin is therefore extensible in the same way the host is. The host is not privileged.
Slots are fan-in: many components render into one place, ordered by order ascending.
Published components#
A component published by name can be used by any other plugin as a first-class widget:
// media-finder publishes it
SDK.registerPlugin({
key: 'media-finder',
components: { MediaPicker },
});
// any plugin consumes it
const { PluginComponent } = SDK.components;
<PluginComponent name="MediaPicker" value={url} onChange={setUrl} />
If the publisher is not installed, PluginComponent renders its fallback:
<PluginComponent
name="MediaPicker"
fallback={<Input value={url} onChange={e => setUrl(e.target.value)} />}
value={url}
onChange={setUrl}
/>
That fallback is what makes media-finder an optional dependency: without it the user
types a URL by hand instead of picking a file, and nothing breaks. Publish a component only
with a documented prop contract — it becomes an API the moment another plugin uses it.
Components differ from slots: a slot is fan-in with no props from the consumer, a published component is fan-out with a prop contract.
A complete screen#
const SDK = window.AdminSDK;
const { React } = SDK;
const { Container, DataTable, CanRender } = SDK.components;
const { Button } = SDK.ui;
const { useListQuery, useConfirmDelete } = SDK.hooks;
function ArticleList() {
const { rows, loading, query, setQuery, refresh } = useListQuery('/articles');
const confirmDelete = useConfirmDelete();
return (
<Container title="Articles">
<DataTable
rows={rows}
loading={loading}
query={query}
onQueryChange={setQuery}
columns={[
{ key: 'name', label: 'Title', sortable: true },
{ key: 'status', label: 'Status' },
{ key: 'created_at', label: 'Created', sortable: true },
]}
toolbar={
<CanRender action="articles.create">
<Button onClick={() => SDK.router.navigate('/posts/new')}>New article</Button>
</CanRender>
}
onDelete={(row) => confirmDelete(`/articles/${row.id}`).then(refresh)}
/>
</Container>
);
}
SDK.registerPlugin({
key: 'article',
routes: [{ path: '/posts', element: ArticleList }],
});
See SDK surface for everything available on window.AdminSDK.