# 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`](https://nextlyhq.com/docs/plugins/stability.md) while its shape settles.

## Component paths

Admin contributions reference React components by **string path**, not by import, so the
declarative surface stays serializable and Node-safe:

```
"<package>/<subpath>#<ExportName>"
// e.g. "@acme/nextly-plugin-reports/admin#ReportsView"
```

Register the components those paths resolve to from your plugin's `/admin` entry, which
runs inside the admin shell:

```ts
// src/admin/index.ts
import { registerComponents } from "@nextlyhq/plugin-sdk/admin";
import { ReportsView } from "./ReportsView";

registerComponents({
  "@acme/nextly-plugin-reports/admin#ReportsView": ReportsView,
});
```

Running `nextly generate:types` emits a `plugin-admin-imports.generated.ts` import map so
these load with no manual host wiring (D60).

## UI building blocks (`@nextlyhq/ui`)

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:

```tsx
// src/admin/ReportsView.tsx
import {
  Button,
  Input,
  Checkbox,
  Select,
  Card,
  Table,
  FormLabelWithTooltip,
} from "@nextlyhq/ui";
```

Which of these primitives are covered by a stability guarantee is recorded in
[`packages/ui/STABILITY.md`](https://github.com/nextlyhq/nextly/blob/main/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.

## Styling your admin UI

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:

```tsx
import { Card, Stack, Grid, Stat } from "@nextlyhq/plugin-sdk/admin";

<Card>
  <Stack gap={4}>
    <Grid cols={3} gap={4}>
      <Stat label="Submissions" value={1280} />
      <Stat label="This week" value={64} />
      <Stat label="Conversion" value="4.2%" />
    </Grid>
  </Stack>
</Card>;
```

**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.

```css
/* src/admin.css — author against the shared token preset */
@import "tailwindcss";
@import "@nextlyhq/ui/theme.css";
@source "./src";
```

```jsonc
// package.json
{ "scripts": { "build:css": "nextly-build-admin-css src/admin.css dist/admin.css" } }
```

```ts
// your plugin definition
contributes: { admin: { styles: "@acme/plugin/dist/admin.css" } }
```

Then side-effect-import the built file from your admin entry so the host app's bundler
loads it:

```ts
// src/admin/index.ts
import "../../dist/admin.css";
```

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.

## Declarative contributions

```ts
contributes: {
  admin: {
    // Sidebar entries (D20). One level of children is supported.
    menu: [
      { label: "Reports", to: "/admin/plugins/reports", icon: "BarChart",
        order: 10, requiredPermission: "read-reports" },
    ],
    // Custom pages, namespaced under /admin/plugins/<slug>/<path>, RBAC-gated (D21).
    pages: [
      { path: "summary", component: "@acme/nextly-plugin-reports/admin#ReportsView",
        requiredPermission: "read-reports" },
    ],
    // A settings panel rendered at /admin/plugins/<slug> (D21).
    settings: { component: "@acme/nextly-plugin-reports/admin#ReportsSettings" },
    // Per-collection view overrides + injection points, keyed by slug (D23).
    views: {
      posts: {
        beforeList: "@acme/nextly-plugin-reports/admin#PostsBanner",
        edit: "@acme/nextly-plugin-reports/admin#PostsEditor",
      },
    },
  },
}
```

`menu`/`pages`/`widgets` honour `requiredPermission` for client-side gating (see
[Permissions](https://nextlyhq.com/docs/plugins/permissions.md)). View override slots are `list`, `edit`,
`beforeList`, `afterList`, `beforeEdit`, `afterEdit`.

## Dashboard widgets

A widget is drawn one of two ways, and you pick by what you declare.

### Let the host draw it

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:
>
> ```ts
> {
>   id: "acme/shortcuts",
>   title: "Shortcuts",
>   archetype: "actions",
>   defaultSize: "sm",
>   actions: [
>     { label: "New post", href: "/admin/collections/posts/create" },
>     { label: "Invite user", href: "/admin/users/create", requiredPermission: "create-users" },
>     { label: "Docs", href: "https://nextly.dev/docs", external: true },
>   ],
> }
> ```
>
> 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.

```ts
contributes: {
  admin: {
    widgets: [
      {
        id: "reports-summary",
        title: "Published reports",
        archetype: "metric",                 // metric | table | list | text | actions
        defaultSize: "sm",                   // sm | md | lg | xl | full
        query: { source: "collection:reports", op: "count" },
        requiredPermission: "read-reports",  // client-gated
        defaultOrder: 40,                    // optional; lower sits higher
      },
    ],
  },
}
```

`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.)

### Where a card sits

`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.

### Widgets that draw their own surface

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.

> [!NOTE]
> `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.

### Draw it yourself

Ship a `component` and the host gives you the card's frame — header, footer, loading and
error states — while you draw the body:

```ts
contributes: {
  admin: {
    widgets: [
      {
        id: "reports-chart",
        component: "@acme/nextly-plugin-reports/admin#ChartWidget",
        size: "half",                        // "full" | "half"
        requiredPermission: "read-reports",  // client-gated
      },
    ],
  },
}
```

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:

```ts
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.

Everything else on this page is `@public` today.

## Placement & appearance

The plugin's own `admin` field controls where its items live (D20):

```ts
import { definePlugin } from "@nextlyhq/plugin-sdk";

definePlugin({
  name: "@acme/nextly-plugin-reports",
  version: "1.0.0",
  nextly: ">=0.0.2-alpha.63",
  // ...
  admin: {
    placement: "collections", // collections | singles | users | settings | plugins | standalone
    order: 10, // lower = higher; default 100
    after: "collections", // anchor for standalone icons
  },
});
```

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.

## Host overrides (D49)

An integrator can override a plugin's placement without forking it, via
`defineConfig({ admin: { pluginOverrides } })` — keyed by plugin name:

```ts
// nextly.config.ts
import { AdminPlacement, defineConfig } from "nextly";
import { reports } from "@acme/nextly-plugin-reports";

export default defineConfig({
  plugins: [reports()],
  admin: {
    pluginOverrides: {
      "@acme/nextly-plugin-reports": {
        placement: AdminPlacement.SETTINGS, // move it
        order: 5,
        after: "users",
        appearance: { label: "Analytics" }, // shallow-merged
      },
    },
  },
});
```

The override wins over the plugin's own `admin` config, so operators stay in control of
their sidebar.

See also: [Permissions](https://nextlyhq.com/docs/plugins/permissions.md) · [Author guide](https://nextlyhq.com/docs/plugins/author-guide.md).
