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

Admin plugins

Routes, layout and slot contributions, plus the data-layer conventions the admin expects.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

An admin plugin is a plain object:

// @orthacms/bootstrap-admin
type AdminPlugin = {
    name: string;
    routes?: RouteItem[];   // { path, element, public? } — top-level routes
    layout?: ReactNode;     // the shell's; don't contribute one
    slots?: SlotContribution[];
};

A feature that lives inside a workspace adds no top-level route. It fills the workspace shell’s slots instead.

A complete plugin

The admin half of reviews: a page at /workspaces/:id/reviews, a sidebar entry, and a block in the entry editor’s Properties panel.

// packages/reviews/admin/src/lib/reviewsPlugin.tsx
import { Suspense } from 'react';
import { MessageSquare } from 'lucide-react';
import type { AdminPlugin } from '@orthacms/bootstrap-admin';
import { ENTRY_SIDEBAR_WIDGET_SLOT } from '@orthacms/content-admin';
import { Spinner } from '@orthacms/design-system';
import { lazyPage } from '@orthacms/utils-admin';
import { WORKSPACE_NAV_SLOT, WORKSPACE_ROUTE_SLOT } from '@orthacms/workspaces-admin';
import { EntryReviewsWidget } from './EntryReviewsWidget';

// Code-split: a page nobody opens is not in the initial bundle. lazyPage
// (0.11.0) also preloads it once the admin is idle, and renders it without
// suspending once it has arrived — so a first visit shows no skeleton.
const ReviewsPage = lazyPage(() => import('./ReviewsPage'), 'ReviewsPage');

export function ReviewsPlugin(): AdminPlugin {
    return {
        name: 'reviews',
        slots: [
            {
                slot: WORKSPACE_ROUTE_SLOT,
                items: [
                    {
                        path: 'reviews', // relative to /workspaces/:id
                        order: 70,
                        element: (
                            <Suspense fallback={<Spinner />}>
                                <ReviewsPage />
                            </Suspense>
                        )
                    }
                ]
            },
            {
                slot: WORKSPACE_NAV_SLOT,
                items: [
                    {
                        labelId: 'reviews.nav.label',
                        defaultLabel: 'Reviews',
                        to: 'reviews', // relative too — never spell out the id
                        order: 45,
                        icon: MessageSquare,
                        permission: 'content:read'
                    }
                ]
            },
            {
                slot: ENTRY_SIDEBAR_WIDGET_SLOT,
                items: [{ id: 'reviews.entry.widget', Component: EntryReviewsWidget }]
            }
        ]
    };
}

Register it in apps/admin/src/plugins.ts, after ShellPlugin() and WorkspacesPlugin() — see plugins.

The data layer

New plugins use the layered layout — domain/, application/, infrastructure/, presentation/ — as users/admin, content/admin and workspaces/admin do. Nothing in presentation/ imports the HTTP client; the boundary lint enforces it.

// infrastructure/reviewsKeys.ts — one key factory, so invalidation isn't guesswork
export const reviewsKeys = {
    all: (workspaceId: string) => ['reviews', workspaceId] as const,
    byEntry: (workspaceId: string, entryId: string) =>
        ['reviews', workspaceId, 'entry', entryId] as const
};
// infrastructure/httpReviewsGateway.ts — the only file that touches HTTP
import { apiClient } from '@orthacms/utils-admin';
import { toReview, type Review, type ReviewResponse } from './reviewMapper';

export const httpReviewsGateway = {
    async listForEntry(entryId: string): Promise<Review[]> {
        // apiClient: baseURL /api, cookie included, X-Workspace-Id attached
        // (since 0.12.0 a request that sets X-Workspace-Id itself keeps it)
        const { data } = await apiClient.get<ReviewResponse[]>('/reviews', {
            params: { entryId }
        });
        return data.map(toReview);
    },
    async create(entryId: string, body: string): Promise<Review> {
        const { data } = await apiClient.post<ReviewResponse>('/reviews', { entryId, body });
        return toReview(data);
    }
};
// application/useEntryReviews.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentWorkspace } from '@orthacms/workspaces-admin';
import { httpReviewsGateway } from '../infrastructure/httpReviewsGateway';
import { reviewsKeys } from '../infrastructure/reviewsKeys';

export function useEntryReviews(entryId: string | undefined) {
    const workspace = useCurrentWorkspace();
    return useQuery({
        queryKey: reviewsKeys.byEntry(workspace.id, entryId ?? ''),
        queryFn: () => httpReviewsGateway.listForEntry(entryId as string),
        enabled: !!entryId
    });
}

export function useCreateReview(entryId: string) {
    const workspace = useCurrentWorkspace();
    const queryClient = useQueryClient();
    return useMutation({
        mutationFn: (body: string) => httpReviewsGateway.create(entryId, body),
        onSuccess: () =>
            queryClient.invalidateQueries({ queryKey: reviewsKeys.byEntry(workspace.id, entryId) })
    });
}
// presentation/EntryReviewsWidget.tsx
import { defineMessages, useIntl } from 'react-intl';
import { EntrySidebarSection, type EntrySlotContext } from '@orthacms/content-admin';
import { Spinner } from '@orthacms/design-system';
import { useHasPermission } from '@orthacms/identity-admin';
import { useEntryReviews } from '../application/useEntryReviews';

const messages = defineMessages({
    title: { id: 'reviews.widget.title', defaultMessage: 'Reviews' },
    failed: { id: 'reviews.widget.failed', defaultMessage: 'Reviews could not be loaded.' },
    empty: { id: 'reviews.widget.empty', defaultMessage: 'No reviews yet.' }
});

export function EntryReviewsWidget({ entry, isCreate }: EntrySlotContext) {
    const intl = useIntl();
    const canRead = useHasPermission('content:read');
    const { data, isPending, isError } = useEntryReviews(isCreate ? undefined : entry?.id);

    if (!canRead || isCreate || !entry) return null;

    return (
        <EntrySidebarSection title={intl.formatMessage(messages.title)}>
            {isPending ? <Spinner /> : null}
            {/* Error and empty are different states — never render one as the other */}
            {isError ? <p>{intl.formatMessage(messages.failed)}</p> : null}
            {data?.length === 0 ? <p>{intl.formatMessage(messages.empty)}</p> : null}
            {data?.map((review) => <p key={review.id}>{review.body}</p>)}
        </EntrySidebarSection>
    );
}
  • Copy is localized. Each component co-locates defineMessages; there is no shared messages.ts. The host provides the single IntlProvider.
  • A mapper (toReview) turns wire shapes into view shapes. Fail loudly on missing data — never a plausible fallback.
  • Some older packages (i18n, transfer) still use per-hook api/use*/ folders. Each package’s AGENTS.md says which layout it is in; author in that one.
  • The Properties panel is closed until the reader opens it (since 0.12.0 it opens over the form, or is pinned beside it). A widget there is for detail. Something the author must see before publishing belongs in ENTRY_STATUS_ITEM_SLOT, which the header always shows — see the entry editor’s slots.

Permission gating

import { useHasPermission } from '@orthacms/identity-admin';

const canPublish = useHasPermission('content:publish'); // fail-closed while loading
  • Gate the nav item (permission on the slot item) and the component.
  • It is a courtesy. The server enforces it regardless.

Routes and layout

  • Top-level routes split on public. Public routes mount as siblings; everything else mounts under the one layout. Only identity contributes public routes (sign-in, accept-invite, reset).
  • A private route is gated only because the shell’s layout wraps it in identity’s RequireAuth.

A second layout takes the auth gate with it

Two layouts: the first in registration order wins, and the host warns. A layout registered ahead of the shell’s replaces RequireAuth, the sidebar and the <main> landmark — every private route renders ungated. Never contribute a layout from a feature plugin.

Two plugins claiming the same route path also get a warning. React Router picks by rank, not order, so the winner is unspecified — rename one.

Pitfalls the review checklist calls out

PitfallWhat happens
Not clamping the page after a deleteDeleting the last row of page 4 leaves you on an empty page 4
Conflating error and emptyA failed request reads “nothing here yet”; the user creates a duplicate
A mapper with a silent fallbackMissing data becomes plausible-looking wrong data
Over-invalidatingOne save refetches half the app
Under-invalidatingA sibling record keeps showing pre-save values

A write returns the canonical record: seed the cache with it, and invalidate the type’s other cached records — one save can rewrite rows it did not name (the i18n sync writes a shared field to every locale sibling).

Next

  • Slots — every extension point and its item shape.
  • Testing — the Playwright harness.
  • The admin-plugin and accessibility skills are the full checklists.