Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Slots

Named extension points, the ones that ship, and the rule that makes them safe.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A slot is a named extension point. One plugin defines it and renders it; others contribute items as plain data. No import between them beyond the slot token.

// @orthacms/utils-admin
function createSlot<T>(name: string): Slot<T>;           // define
type SlotContribution<T> = { slot: Slot<T>; items: T[] }; // contribute

Filling a slot

import type { AdminPlugin } from '@orthacms/bootstrap-admin';
import { SIDEBAR_NAV_SLOT } from '@orthacms/shell-admin';
import { COPILOT_TOOL_RESULT_SLOT } from '@orthacms/copilot-admin';
import { MessageSquare } from 'lucide-react';
import { ReviewListResult } from './ReviewListResult';

export function ReviewsPlugin(): AdminPlugin {
    return {
        name: 'reviews',
        slots: [
            {
                slot: SIDEBAR_NAV_SLOT,
                items: [
                    {
                        labelId: 'reviews.nav.label',
                        defaultLabel: 'Reviews',
                        to: '/reviews',
                        group: 'overview', // or 'directory'
                        order: 40,
                        icon: MessageSquare,
                        permission: 'content:read'
                    }
                ]
            },
            {
                // Render the review_list tool's output as a card in the copilot chat
                slot: COPILOT_TOOL_RESULT_SLOT,
                items: [{ id: 'reviews.copilot.list', toolName: 'review_list', Component: ReviewListResult }]
            }
        ]
    };
}
// ReviewListResult.tsx — receives the call's name, input and output
import type { CopilotToolResultContext } from '@orthacms/copilot-admin';

export function ReviewListResult({ output }: CopilotToolResultContext) {
    const { items } = output as { items: { id: string; body: string }[] };
    return <ul>{items.map((r) => <li key={r.id}>{r.body}</li>)}</ul>;
}

The item’s type is the slot’s. Two common shapes:

  • Nav items — labelId, defaultLabel, to, order, icon, optional permission.
  • Component items — id, order (on most), Component.

The catalogue

App shell — @orthacms/shell-admin

SlotFillsItem
SIDEBAR_NAV_SLOTTop-level sidebar entriesNav item + group (overview or directory)
SIDEBAR_SECTION_SLOTA whole sidebar section{ id, order, Component }
SIDEBAR_FOOTER_SLOTThe sidebar footer{ id, order, Component }
HOME_SECTION_SLOTThe home page, by region: action, attention, stat or panel{ id, region, order, Component }
COMMAND_SLOTA result group in the command paletteComponent item; receives { close }

Inside a workspace — @orthacms/workspaces-admin

Paths and to are relative to /workspaces/:id.

SlotFillsItem
WORKSPACE_NAV_SLOTWorkspace sidebar entriesNav item
WORKSPACE_SECTION_SLOTA workspace sidebar section{ id, order, Component, takeover? }
WORKSPACE_ROUTE_SLOTRoutes in the workspace shell{ path, element, order? }
WORKSPACE_SETTINGS_TAB_SLOTA section of the settings page, listed in its side nav{ id, path, labelId, defaultLabel, descriptionId?, defaultDescription?, icon, order, permission?, element }

A section’s optional takeover(pathname) (since 0.13.0) makes it the sidebar’s only content wherever it returns true: the other sections and the Tools group step aside, and the workspace header stays. The copilot’s Agents view swaps the content tree for its chat list that way.

Content library — @orthacms/content-admin

Eighteen slots. Content defines them and fills none itself. The entry-editor components receive EntrySlotContext — schema, entry, isCreate, mode, workspaceId, typePath, params, tabSegment.

SlotFills
RECORDS_TOOLBAR_SLOTControls in the records table’s toolbar
RECORDS_MENU_SLOTWhole-collection actions, in the toolbar’s ⋯ menu
RECORDS_COLUMN_SLOTExtra columns in the records table
RECORDS_FILTER_FIELDS_SLOTVirtual filterable fields
RECORDS_BULK_ACTION_SLOTActions on a selection
ENTRY_TAB_SLOTA view of the record, as a tab between Content and History
ENTRY_HEADER_SLOTA control leading the top bar’s actions (beside the title on a phone)
ENTRY_STATUS_ITEM_SLOTA status item the header’s status cluster lists
ENTRY_TIMELINE_SLOTRows between the versions of the History timeline
ENTRY_SIDEBAR_WIDGET_SLOTA block in the Properties panel — render EntrySidebarSection
ENTRY_DETAILS_ROW_SLOTA row in the panel’s Details block — render EntrySidebarRow
ENTRY_FIELD_CONTROL_SLOTThe control a field type renders as, in the one Content form
ENTRY_MENU_SLOTItems in the entry’s action menu
ENTRY_PARAMS_SLOTExtra query parameters on entry requests
ENTRY_PRESAVE_SLOTA hook that runs before a save
ENTRY_PUBLISH_GUARD_SLOTA verdict on whether this entry may be published, and why not
REVISION_EXTRA_SLOTRows in a revision preview
CONTENT_OVERLAY_SLOTAn overlay over the content area

Elsewhere

SlotPackageFills
INSIGHTS_WIDGET_SLOT@orthacms/insights-adminA card on the Insights dashboard
INSIGHTS_SECTION_SLOT@orthacms/insights-adminA section of the dashboard, drawn as a tab
INSIGHTS_SIGNAL_SLOT@orthacms/insights-adminA line in Needs attention, and the drill-down panel behind it
WYSIWYG_MEDIA_SLOT@orthacms/wysiwyg-adminA media source in the rich-text editor
COPILOT_TOOL_RESULT_SLOT@orthacms/copilot-adminA rich rendering of a tool’s result, by toolName

There is no general top-bar slot: a page fills its own bar with PageActionsPortal, and Home’s bar holds the action items of HOME_SECTION_SLOT. The copilot’s launcher — Ask Ortha AI, in the strip under the work area since 0.9.0 — is a SIDEBAR_FOOTER_SLOT item that places itself with the design system’s InsetFooterEnd — not an extension point you can fill.

The entry editor’s slots

Since 0.12.0 the entry editor is one Content form in schema order, with History and any contributed tab beside it, and its properties in a Properties panel that opens over the form on demand (ADR-0022). The slots follow that shape.

Status items — plugins report, content draws. An ENTRY_STATUS_ITEM_SLOT item is an EntryStatusItemContribution:

type EntryStatusSeverity = 'blocking' | 'warning' | 'info';

type EntryStatusItem = {
    id: string;
    severity: EntryStatusSeverity;
    label: string;   // already localized: "Review requested"
    detail?: string; // one line under it in the list
    target?: { field: string } | { label: string; onSelect: () => void };
};

type EntryStatusItemContribution = {
    id: string;
    order?: number; // among items of the same severity
    appliesTo?: (schema: ContentTypeDetail) => boolean;
    useItem: (context: EntrySlotContext, state: { dirty: boolean }) => EntryStatusItem | null;
};
  • useItem is a hook, called on every render, so it may run queries. Return null when there is nothing to say.
  • Content merges the items with the form’s own field issues (one “N issues” chip) and a publish guard’s refusal, and draws the cluster: at most two chips, most severe first, the rest behind +N, and the full list grouped Blocks publishing / Heads-up. A field target gets Go to field. A plugin never draws a header chip itself.
  • An info item takes no chip while something blocks publishing.
  • @orthacms/alarms-admin reports the record’s open check findings — a warning when one is an error, else info. Alarms never block.

Timeline sources. An ENTRY_TIMELINE_SLOT item is { id, label, useEvents }. useEvents(context) returns { events, isPending, isError }, or null when the reader may not see the source — then History offers no filter for it. Each event is { id, at, title, actor? }, already localized; content sorts and groups them by day between the versions. @orthacms/activity-admin contributes the record’s activity, filtered as Activity beside All and Versions.

Field controls. An ENTRY_FIELD_CONTROL_SLOT item owns the input; content keeps the label row, the description and the error. Since 0.12.0 the control’s context also carries mediaRefs and mediaRefsPending (the saved entry’s resolved refs for that field) and presave (the ENTRY_PRESAVE_SLOT handles, by item id). An item that sets composite: true is several controls rather than one input, so its field is named as a group instead of by a <label for>. @orthacms/media-admin renders media fields this way, inline in the form, and no longer adds a tab of its own.

Tabs are views, not kinds of field. ENTRY_TAB_SLOT slugs are a closed set (ENTRY_TAB_SLUGS). @orthacms/segments-admin’s Access is the one shipped contribution; media and activity no longer add tabs. relations, media and activity stay in ENTRY_TAB for one release as redirect-only slugs: …/relations and …/media open the Content form, …/activity opens History with ?show=activity.

The Properties panel. ENTRY_SIDEBAR_WIDGET_SLOT and ENTRY_DETAILS_ROW_SLOT keep their contracts and render in the Properties panel. Do not put anything there that a reader needs to decide a save or a publish: the panel is closed until opened.

What this buys

  • The rich-text editor is one ENTRY_FIELD_CONTROL_SLOT item — no route, no nav entry. It appears wherever an entry form does. The media field is another.
  • Insights ships no widgets. Content, media, i18n and protection fill its widget slot. The dashboard never learns about them.

Rules

  • Boot-frozen. Items are wired once at boot; nothing adds or removes one at runtime. That is what lets a render site call a hook per item in a loop.
  • Gate on the item and inside the component. The renderer must know whether a section has visible items before drawing its heading.
  • Sort when you render. getItems() returns registration order; the shipped render sites sort with byOrder from @orthacms/utils-admin.

Defining your own

// packages/reviews/admin/src/lib/reviewsSlots.ts
import type { ComponentType } from 'react';
import { createSlot } from '@orthacms/utils-admin';

export type ReviewsPanelItem = {
    id: string;
    order: number;
    permission: string;
    Component: ComponentType; // zero props: read what you need from context
};

export const REVIEWS_PANEL_SLOT = createSlot<ReviewsPanelItem>('reviews.panel');
// where you render it
import { byOrder } from '@orthacms/utils-admin';
import { useHasPermission } from '@orthacms/identity-admin';
import { REVIEWS_PANEL_SLOT, type ReviewsPanelItem } from './reviewsSlots';

const items = byOrder(REVIEWS_PANEL_SLOT.getItems());

function PanelItem({ item }: { item: ReviewsPanelItem }) {
    const allowed = useHasPermission(item.permission);
    return allowed ? <item.Component /> : null;
}

export function ReviewsPanels() {
    return items.map((item) => <PanelItem key={item.id} item={item} />);
}

Export the token and item type from your package index so other plugins can import them.

Prefer a zero-prop component

Props are a contract both sides must change together; context is one the render site already provides. The entry-editor slots are the exception — their components receive EntrySlotContext, because only the editor knows which record is open.