An admin plugin is a plain object:
type AdminPlugin = {
name: string;
routes?: RouteItem[];
layout?: ReactNode;
slots?: SlotContribution[];
};export const reviewsPlugin: AdminPlugin = {
name: 'reviews',
routes: [
{
path: '/workspaces/:id/reviews',
element: lazy(() => import('./presentation/pages/ReviewsPage'))
}
],
slots: [
{
slot: WORKSPACE_NAV_SLOT,
items: [{ id: 'reviews', label: 'Reviews', to: 'reviews', permission: 'reviews:read' }]
}
]
};Routes
Routes are flattened from every plugin and split by a public flag. Public
routes mount as top-level siblings; everything else mounts under one pathless
parent whose element is the contributed layout.
Code-split them. A route element should be lazily imported, so a page nobody opens is not in the initial bundle.
Layout
The shell plugin contributes the layout, and it is the one that composes identity’s auth provider and gate.
A second layout takes the auth gate with it
Two plugins contributing a layout is “first one wins”, decided by registration
order. The host warns and names both, but it cannot choose for you.
This is the collision that bites: the shell’s layout is what composes
RequireAuth, the sidebar, the skip link and the <main> landmark. A layout
registered ahead of it takes all of that, and every private route renders
ungated.
Do not contribute a layout from a feature plugin.
Two plugins claiming the same route path are the same “first one wins” with the same warning.
Slots
A slot is a named extension point another plugin defined. Contributions are data, wired once at boot and read sorted.
See slots for the catalogue and the semantics.
The data layer
The convention across the admin plugins:
- One gateway per plugin — the single place
apiClientis used, holding every request function. Nothing else in the plugin imports the HTTP client. - TanStack Query hooks call the gateway, never
apiClientdirectly. - A mapper converts wire shapes to view shapes — the anti-corruption layer.
- Query keys live in one file so invalidation is not guesswork.
The larger plugins are laid out in four layers — domain, application,
infrastructure, presentation — with domain holding pure TypeScript, no
React and no transport. content/admin and workspaces/admin are the reference
implementations.
Permission gating
const canPublish = useHasPermission('content:publish');Gate the navigation item and the component. A nav section has to know whether it has any visible children before it renders its own heading, or a user without the permission gets a heading sitting over nothing.
Gating the UI is a courtesy. The server enforces it regardless.
Copy is localized
Each component co-locates its own messages:
const messages = defineMessages({
title: { id: 'reviews.title', defaultMessage: 'Reviews' }
});There is no shared messages.ts. The host provides the single IntlProvider.
Pitfalls the review checklist calls out
These are real bugs that have shipped in this codebase, and they are worth knowing before writing your first page.
| Pitfall | What happens |
|---|---|
| Not clamping the page after a mutation | Deleting the last row of page 4 leaves you on an empty page 4 |
| Conflating error and empty | A failed request renders as “nothing here yet”, and 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 | One save leaves a sibling record showing pre-save values |
That last pair is a genuine tension. A write returns the canonical record, so seed the cache with it rather than invalidating the read — but invalidate the other cached records of the type, because one save can rewrite rows it did not name: the localization sync writes a shared field to every locale sibling.
What to read next
The admin-plugin and accessibility skills in the repository are the full
checklists.