You're reading docs for Nextly Alpha. APIs may change between releases.
Plugins
Admin UI
Contribute admin menu items, pages, settings, and collection-view overrides from a plugin — and control where they appear in the sidebar.
A plugin can extend the Nextly admin in two complementary ways:
contributes.admin. The declarative surface: menu entries, custom pages,
a settings panel, and per-collection view overrides (D19–D23).
admin (on the plugin definition) — placement & appearance: where the
plugin's items sit in the sidebar and how they look (D20).
Both are @public. Admin dashboard widgets (PluginAdminWidget, D22) are rendered
on the admin dashboard today, but the contract remains
@experimental while its shape settles.
Build your admin components out of the shared plugin UI kit, @nextlyhq/ui — the
semver-protected surface of React primitives the admin itself is built from (D68/D53).
The host provides it (and React) as a peer dependency, so you and the admin share one
version and one theme:
Which of these primitives are covered by a stability guarantee is recorded in
packages/ui/STABILITY.md:
a component becomes @public once a first-party plugin depends on it, and everything
else is @experimental and may change in any release.
Register components and reference admin contribution types through
@nextlyhq/plugin-sdk/admin; import UI primitives from @nextlyhq/ui.
Never import from @nextlyhq/admin. It is an application, not a published API —
reaching into it (e.g. @nextlyhq/admin/lib/*) is unsupported and will break. If a
primitive you need isn't yet exported from @nextlyhq/ui, open an issue so it can be
promoted to the kit rather than copied.
The admin ships a single, precompiled, isolated stylesheet (every rule scoped under
.nextly-admin). Because it is built ahead of time, Tailwind cannot see utilities that
live only in your package — so there are three ways to style a plugin, in order of
preference:
1. Compose from the kit.@nextlyhq/ui components are already styled by the admin
sheet, so a plugin built from them needs no styling work at all. Reach for the layout
primitives for common structure:
2. Use safelisted utility classes. A curated, token-driven set of Tailwind utilities is
always present in the admin sheet, so you can use them directly in your components with no
build step: layout (flex, grid, grid-cols-{1..6}, gap-{0..8}, items-*,
justify-*), spacing (p-*, m-*, space-*), sizing (w-*, max-w-*, size-*),
typography (text-{xs..2xl}, font-*, truncate, line-clamp-*), token colors
(bg-primary, text-muted-foreground, border-border, …), and borders/radius/shadow.
These are token-driven only — no arbitrary values or raw colors, so your UI themes in
light and dark automatically.
3. Ship your own CSS (admin.styles). For anything beyond the safelist, compile a
scoped, token-driven stylesheet with the nextly-build-admin-css CLI (from
@nextlyhq/admin-css, add it as a devDependency) and declare it in your manifest.
Your CSS is compiled in your package, not by the host. That is deliberate: the admin
sheet is built when Nextly is released, so it can never scan a plugin that is installed
later, and pointing Tailwind at an installed package with @source is not a reliable
substitute — it does not accept package references, and it resolves inconsistently under
pnpm, which is what this repository and many plugin projects use. Compiling ahead of time
means your styles do not depend on the host's build at all.
/* src/admin.css — author against the shared token preset */@import "tailwindcss";@import "@nextlyhq/ui/theme.css";@source "./src";
The CLI compiles your Tailwind, scopes every rule under .nextly-admin, and refuses to
emit if a rule would escape that wrapper or if it finds a hardcoded color — so your styles
can never leak into the host page and always theme correctly. Use --nx-* tokens (via
var() or the token utilities), never literal colors.
Declare an archetype and the query it is drawn from, and ship no UI code. The host
runs the query for the signed-in user and renders the card.
What the host draws today: metric, list, table and actions.text is accepted by the contract but has no renderer yet, so a widget
declaring it shows "the text archetype is not rendered yet" in its card unless
it also ships a component to draw the body itself.
A list widget draws one row per result. Its query's select decides what each
row shows, in order: the first field is the row's label and the second, if there
is one, is the muted line under it.
A table widget draws a column per selected field, headed by that field's label
from your collection — so a heading reads "Published at", not publishedAt. The
columns come from what the SERVER returned, not from select: a field carrying
an access.read rule that denies the viewer is absent from their rows, and its
column and heading are absent with it.
A list or table that selects nothing says so rather than guessing at a field,
and says it without running a query.
An actions widget is a short column of shortcuts, declared rather than
queried:
Each item is gated on its own requiredPermission: a reader who may not use a
shortcut does not see it, while the card itself stays. That is separate from
the widget's own requiredPermission, which decides whether the card appears
at all. external: true opens the destination in a new tab and says so to a
screen reader. A card shows at most six shortcuts and counts the rest.
text and actions are drawn without data, so they take no query — declaring one is
a type error. Every other archetype requires it. (actions is drawn by the host; text
is not yet — see the note above.)
defaultOrder is optional and sorts ascending; a widget that omits it goes after every
widget that states one. Leave it off unless you mean it — without it your card keeps its
declaration order, which is what nearly every widget wants.
It exists because position would otherwise depend on how a widget reached the
dashboard rather than on anything it said: contributions resolve before registrations, so
the same card moved across the grid when its author switched channels. Core's own
dashboard cards state one, which is how they stay above contributed widgets.
Numbers, not indexes — core spaces its own ten apart so a card can be placed between two
without renumbering anything.
A reader's saved layout, once they have arranged their dashboard, wins over this. Think
of it as where the card starts, not where it stays.
A custom widget may set chrome: "none" to decline the card frame entirely, and is
then responsible for everything the card would have provided — its heading, its loading
and error states, its own visual boundary.
Reach for it only when your component is already a designed surface, such as a
titled section with its own rules. Framing one of those draws a second heading above its
own. If your component draws a body and expects a title around it, leave chrome alone.
It is refused on every other archetype, because core composes those bodies into the
card — the card owns the title, the footer and the busy state — so an unframed one would
render with no heading and nothing reporting that it was loading.
One consequence worth knowing: an unframed component that renders nothing costs no space
at all. The grid collapses a cell whose widget drew nothing, so a card that hides itself
when it has nothing to say leaves no gap behind.
core# is reserved. Core's own dashboard cards resolve their components under that
prefix, and the admin refuses to let anything else register or unregister one — a
plugin claiming the path would replace the body drawn for a card the registry still
attributes to core.
A component-drawn widget may ALSO declare a query: the host runs it in the same batch
and hands your component the result, so you do not fetch it again.
Your component receives five props, and you can name their types:
import type { WidgetComponentProps } from "@nextlyhq/plugin-sdk/widgets";export function ChartWidget({ slot, isFetching }: WidgetComponentProps) { // `slot` is undefined in TWO cases, and `isFetching` tells them apart: // the first request is still running, or this widget declared no query. if (!slot) return isFetching ? <Skeleton /> : null; if (!slot.ok) return <p>{slot.error}</p>; if (slot.result.op !== "list") return null; return <Chart rows={slot.result.items} busy={isFetching} />;}
Prop
Type
What it is
widgetId
string
The id you registered the widget under.
placementId
string
The card's id. One widget may sit on a dashboard twice, and everything belonging to a card — its settings and its answer — is keyed by this, not by widgetId.
settings
Record<string, unknown>
This card's settings, already resolved: the reader's stored values where they are usable, your declared defaults everywhere else.
slot
WidgetSlot | undefined
This card's answer. undefined means either that the first request is still in flight or that the widget declared no query — see below.
isFetching
boolean
Whether this card's query is in flight, the first request included. Always false for a widget with no query.
An absent slot has two causes and they need different UI: isFetching is true only
for a card whose query is in the batch, so slot === undefined && isFetching is the
first load — draw a skeleton — while slot === undefined && !isFetching means no answer
is ever coming. Treating them alike draws an empty state over a running request.
slot is a discriminated union — { ok: true, result } or { ok: false, error } — so
narrowing on slot.ok gives you a typed result. A failing query is a value, not a
thrown error: the batch answers with every other widget's data intact, so one query
failing colours one card rather than blanking the dashboard.
These types come from @nextlyhq/plugin-sdk/widgets, which carries no runtime code —
importing them adds nothing to your bundle.
Widgets are rendered on the admin dashboard by WidgetGrid, in a 12-column grid that
collapses to one column on small screens, and are hidden from users who lack
requiredPermission. Every widget on the page is fetched in a single batched request.
An archetype this version of Nextly does not draw reports itself in that card and leaves
the rest of the dashboard alone — whether it is one of the four above or a name from a
newer core this admin has never heard of. A plugin ahead of its host degrades one card
rather than breaking the admin.
The contract is still @experimental and is expected to grow: charts, and richer
archetypes. Per-widget settings and saved layouts have since shipped — a widget declares
settings, the reader edits them per card, and your component is handed the resolved
values. Pin your Nextly version while it settles.
requiredPermission gates whether the widget renders. It does not constrain the data
your widget reads — fetch through the access-controlled API so a viewer only ever sees
rows they may read.
Placement defaults to plugins. standalone gives the plugin its own top-level icon,
anchored after the section named by after.
The value is written out rather than read from the AdminPlacement constant, which ships
from nextly and not from @nextlyhq/plugin-sdk. The SDK is the only surface a plugin
should depend on, so reaching into core for a constant would tie your plugin to core's
layout for the sake of one string. A host config, which already imports from nextly,
can use the constant, and does so below.