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 sharedmessages.ts. The host provides the singleIntlProvider. - 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’sAGENTS.mdsays 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 (
permissionon the slot item) and the component. - It is a courtesy. The server enforces it regardless.
Routes and layout
- Top-level
routessplit onpublic. Public routes mount as siblings; everything else mounts under the onelayout. 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
| Pitfall | What happens |
|---|---|
| Not clamping the page after a delete | Deleting the last row of page 4 leaves you on an empty page 4 |
| Conflating error and empty | A failed request reads “nothing here yet”; the user creates a duplicate |
| A mapper with a silent fallback | Missing data becomes plausible-looking wrong data |
| Over-invalidating | One save refetches half the app |
| Under-invalidating | A 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).