Admin Extensibility

Four tiers of customization for the React admin UI.

The admin UI is designed around composition, not overrides. Customizations inject into a working system — you never rebuild a component from scratch to change one thing.

Tier 1 — Config values

Simple branding and entity display. No React knowledge needed for branding; summary callbacks are optional.


          export default config({
  admin: {
    logo: "/logo.svg",
    title: "My CMS",
  },
  // ...
});
        

logo sets the sidebar logo image. title sets the browser tab title.

Entity summary

Control how documents appear in list views and relation pickers by defining a summary on the entity's admin option. The fields array declares which fields to fetch — the admin only loads these for list views instead of the full document.


          const authors = entity("authors", {
  admin: {
    summary: {
      fields: ["name", "bio", "avatar"],
      title: ({ data }) => data.name,
      description: ({ data }) => data.bio,
      media: ({ data }) => data.avatar
        ? <img src={`/api/assets/${data.avatar}`} alt="" />
        : <UserIcon />,
    },
  },
  fields: [/* ... */],
});
        

The data argument in callbacks is typed to only the fields you listed — data.name autocompletes, data.somethingElse is a type error. When summary is omitted, all fields are fetched for list views.

See the Config Reference — admin.summary for the full API.

Entity list columns

Control which columns appear in the entity list view — and in what order — using admin.list. When list is omitted the admin renders a default layout based on admin.summary.


          const posts = entity("posts", {
  admin: {
    list: {
      fields: ["title", "status"],
      defaultSort: { field: "createdAt", order: "desc" },
      columns: [
        {
          label: "Title",
          sortKey: "title",
          render: ({ data }) => data.title,
        },
        {
          label: "Status",
          render: ({ data }) => data.status,
        },
        "createdAt",
      ],
    },
  },
  fields: [/* ... */],
});
        
  • fields — same as summary.fields: limits what the API fetches for the list view.
  • columns — ordered array of column definitions or built-in strings ("createdAt", "updatedAt", "locales"). Each custom column can optionally declare a sortKey to enable server-side sorting for that column. sortKey is type-safe and constrained to scalar fields and dotted paths.
  • defaultSort — sort applied on first load when the URL carries no sort param. The field value must match a valid sortKey or one of the built-in sortable columns. Clicking a column header in the UI overrides it for that session.

See the Config Reference — admin.list for the full API.

Bulk actions

Add actions that operate on multiple selected documents at once. Bulk actions appear in a bar at the bottom of the list view whenever one or more documents are selected.

When an entity has versions: true, Publish and Unpublish are injected automatically — no bulkActions config needed. Use bulkActions for custom editorial operations beyond those built-ins.


          import { StarIcon } from "@phosphor-icons/react";

const posts = entity("posts", {
  admin: {
    bulkActions: [
      {
        label: "Feature",
        icon: StarIcon,
        iconOnly: true,
        callback: ({ set }) => {
          set({ featured: true });
          return {
            success: (n) => `${n} ${n === 1 ? "post" : "posts"} featured`,
            error: "Failed to feature posts",
          };
        },
      },
    ],
  },
  fields: [/* ... */],
});
        

Callback context

The callback receives a context object with two mutually exclusive helpers — use one per action:

| Helper | Description | | --- | --- | | set(data) | Applies a static partial update to all selected documents. | | map(fields, fn) | Fetches the listed fields for all selected documents, then calls fn per document to produce a per-document update. Use when the new value depends on the current document state. |

set is synchronous. map is async — await it before returning messages.


          // map example — append a tag without overwriting existing ones
{
  label: "Mark as featured",
  callback: async ({ map }) => {
    await map(["tags"], (doc) => ({
      tags: [...(doc.tags ?? []), "featured"],
    }));
    return {
      success: (n) => `${n} ${n === 1 ? "post" : "posts"} marked as featured`,
      partialSuccess: (succeeded, failed) => `${succeeded} marked as featured, ${failed} failed`,
      error: "Failed to mark posts as featured",
    };
  },
}
        

set can't do this — it would replace the entire tags array. map fetches the current value per document first, so you can safely append.

Toast messages

The value returned from callback controls the toast shown after the action completes. All keys are optional — omitting one falls back to a generic message.

| Key | Type | Description | | --- | --- | --- | | success | (count: number) => string | Shown when all selected documents updated successfully. | | partialSuccess | (succeeded: number, failed: number) => string | Shown when some documents succeeded and some failed. | | error | string | Shown when all documents failed. |

Confirm modal

Set confirmModal to require an explicit confirmation step before the action runs. The modal is shown after the user clicks the action button.


          confirmModal: {
  title: "Delete drafts?",
  description: "This cannot be undone.",
  actions: {
    confirm: "Delete",  // confirm button label (default: "Confirm")
    cancel: "Keep",     // cancel button label (default: "Cancel")
  },
}
        

See the Config Reference — admin.bulkActions for the full API.

Tier 2 — Injection slots

Add to the UI without replacing anything. Navigation, auth, and routing all keep working.

When you provide a sidebar function, you take full control of the navigation structure. The function receives builder helpers and returns an array of sidebar elements:


          import { config } from "@cms/config";
import { BookOpenIcon, GearIcon } from "@phosphor-icons/react";

export default config({
  admin: {
    sidebar: ({ group, item, divider, builtins }) => [
      builtins.DASHBOARD,
      group("Content", {
        collapsible: true,
        children: [
          item("posts", { label: "Posts", icon: BookOpenIcon }),
          item("authors", { label: "Authors" }),
        ],
      }),
      divider(),
      item("settings", { label: "Settings", icon: GearIcon }),
      divider(),
      builtins.ASSETS,
    ],
  },
  // ...
});
        

Builders

| Builder | Description | | --- | --- | | item(name, options?) | A link to an entity or global. The name is auto-resolved — entities and globals share a unique namespace. Options: label, icon. | | group(label, options) | A labeled section. Options: icon, collapsible, defaultOpen, indent, children. Children can be any sidebar element, including nested groups. | | divider() | A horizontal separator. | | builtins.DASHBOARD | The built-in dashboard item. | | builtins.ASSETS | The built-in assets manager item. |

  • item auto-resolves names: if the name matches an entity it links to /entities/{name}, if it matches a global it links to /globals/{name}.
  • When no label is provided, the entity/global name is used.
  • icon accepts any React component (Phosphor icons recommended).
  • When sidebar is omitted, the default sidebar is used (Dashboard, all entities, all globals, Assets).
  • Entities or globals not included in the sidebar are silently hidden from navigation.

Dashboard

Replace the default dashboard with a custom React component:


          import { config } from "@cms/config";
import { MyDashboard } from "./my-dashboard";

export default config({
  admin: {
    dashboard: MyDashboard,
  },
  // ...
});
        

When provided, the custom component replaces the built-in dashboard entirely. It receives no props — use the CMS hooks (Tier 4) to fetch data.

Tier 3 — Field component overrides

Replace a specific, scoped piece of a field — not the whole page. The form, validation, saving, and permissions are still handled by the framework.

Field-level overrides are defined per-field via admin.components. Each field has four overridable slots: root, label, input, and error. Every custom component receives a Default prop — the built-in component for that slot — so you can wrap, extend, or fully replace any part of a field's rendering.


          import type { InputSlotProps } from "@cms/types";

text("color", {
  admin: {
    components: {
      input: ({ Default, ...props }: InputSlotProps) => <ColorPicker {...props} />,
    },
  },
});
        

Slot props

Each slot receives typed props:

| Slot | Key props | Purpose | | ------- | -------------------------------------- | ------------------------------------------- | | root | Default, field, children | Wraps the entire field (label + input + error) | | label | Default, field, htmlFor | The field label | | input | Default, field, value, onChange | The input control | | error | Default, error, id | The validation error message |

value and onChange are typed as unknown — cast to the expected type at the point of use (e.g., value as string for a text field).

Wrapping vs replacing

To wrap the default, render <Default> and add your customizations around it:


          input: ({ Default, value, ...props }) => (
  <div>
    <Default value={value} {...props} />
    <span>{(value as string)?.length ?? 0} / 100</span>
  </div>
)
        

To replace the default, ignore Default and render your own JSX:


          input: ({ value, onChange }) => (
  <input
    type="color"
    value={value as string}
    onChange={(e) => onChange(e.target.value)}
  />
)
        

See Fields — Custom field components for the full slot reference and custom field patterns.

Tier 4 — Hooks as public API

The same hooks the built-in UI uses are exported from @cms/admin:


          import { useDocuments, useDocument, useMe } from "@cms/admin";

function RevenueWidget() {
  const { data } = useDocuments("orders", {
    filter: { status: "paid" },
    limit: 100,
  });
  return <div>Revenue: {sum(data)}</div>;
}
        

Available hooks

| Hook | Description | | --- | --- | | useConfig() | CMS config — entities, globals, locales | | useMe() | Current authenticated user | | useDocument(entity, id, params?) | Single document fetch. Return type narrows automatically for known entities (e.g. "_image" returns ImageAsset). | | useDocuments(entity, params?) | Document list with filtering, sorting, and pagination | | useGlobalDocument(name, params?) | Single global document fetch | | useEntityDef(name) | Entity definition and field metadata | | useGlobalDef(name) | Global definition and field metadata | | useAssets(types, params?) | Fetch assets by type ("_image", "_video", "_file") |

Query options

The underlying query option factories are also exported, for use with useSuspenseQuery, route loaders, or queryClient.fetchQuery:


          import { getDocumentQueryOptions } from "@cms/admin";
import { useSuspenseQuery } from "@tanstack/react-query";

function Post({ id }: { id: string }) {
  const { data } = useSuspenseQuery(getDocumentQueryOptions("posts", id));
  return <h1>{data.title}</h1>;
}
        

| Query option | Description | | --- | --- | | getDocumentQueryOptions(entity, id, params?) | Single document query options | | getDocumentsQueryOptions(entity, params?) | Document list query options | | getSummaryDocumentsQueryOptions(entity, params?) | Document list with only summary fields — used by list views and relation pickers | | getGlobalDocumentQueryOptions(name, params?) | Global document query options |

Client

The API client instance is exported for imperative use outside React (event handlers, scripts, data migrations):


          import { client } from "@cms/admin";

const { data } = await client.entities.list("posts", { limit: 10 });
        

Custom components built with these primitives are first-class citizens — they use the same data fetching, caching, and auth as the built-in UI.

Design principle

Each tier is additive:

  1. Tier 1: change values
  2. Tier 2: inject into existing structure
  3. Tier 3: swap a specific leaf component
  4. Tier 4: build anything with the same primitives

You never have to rebuild the sidebar from scratch. You either compose it from builders (Tier 2) or swap a single component while keeping all the data/auth/routing plumbing (Tier 3 + Tier 4 hooks).

Previous

Internationalization

Next

Live Preview