Cogeze
Admin UI

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:

tsx
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:

tsx
const SDK = window.AdminSDK;

SDK.registerPlugin({
  key: 'article',
  routes: [
    { path: '/posts', element: ArticleList },
    { path: '/posts/:id/edit', element: ArticleEdit },
  ],
});
ts
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:

tsx
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:

tsx
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:

tsx
// media-finder publishes it
SDK.registerPlugin({
  key: 'media-finder',
  components: { MediaPicker },
});
tsx
// 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:

tsx
<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#

tsx
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.