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.

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:

// 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:

// 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: 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:

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.

/* src/admin.css — author against the shared token preset */
@import "tailwindcss";
@import "@nextlyhq/ui/theme.css";
@source "./src";
// package.json
{ "scripts": { "build:css": "nextly-build-admin-css src/admin.css dist/admin.css" } }
// 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:

// 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

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

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

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.

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:

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:

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} />;
}
PropTypeWhat it is
widgetIdstringThe id you registered the widget under.
placementIdstringThe 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.
settingsRecord<string, unknown>This card's settings, already resolved: the reader's stored values where they are usable, your declared defaults everywhere else.
slotWidgetSlot | undefinedThis card's answer. undefined means either that the first request is still in flight or that the widget declared no query — see below.
isFetchingbooleanWhether 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):

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:

// 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 · Author guide.