Dashboard chrome
The dashboard ships with a fixed shell — a sidebar, a header navbar, a command palette, a footer, an app-wide overlay slot, and the auth pages. A frontend layer adds its own chrome to that shell by calling a register function from a Nuxt plugin; the matching shell component reads the registry and renders your entry. No core component needs to know about your module.
Header actions
registerHeaderAction adds an icon-only button to the right of the dashboard header navbar — the strip that already holds quick actions, notifications, and the user avatar.
Signature
registerHeaderAction(action: HeaderAction): void
useHeaderActions(): { actions: ComputedRef<HeaderAction[]> }
Config shape
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable key; re-registering the same id replaces the entry. |
icon | string | yes | Icon name (e.g. i-ph-cloud-arrow-up-light). The button is icon-only. |
label | string | yes | Used as the button title and aria-label (tooltip / a11y text). |
onSelect | () => void | yes | Run on click. |
order | number | no | Sort key, ascending. Defaults to 100. |
isVisible | () => boolean | no | Re-evaluated reactively; omit to always show. |
When/where to call — From a .client plugin: the entry carries onSelect (and optionally isVisible), which the SSR payload would drop. The consumer filters out any entry whose onSelect is not a function, so a stray server-side registration degrades to "button not shown" rather than a crash.
Where it renders — DashboardHeader.vue, in the navbar #right slot, as a UButton with variant="ghost" and color="neutral". Visible entries are sorted by order.
Example
export default defineNuxtPlugin(() => {
registerHeaderAction({
id: "my-module:sync",
icon: "i-ph-arrows-clockwise-light",
label: "Sync now",
order: 50,
onSelect: () => {
// trigger your action
},
});
});
Command palette sources
registerCommandPaletteSource contributes groups of commands to the Ctrl/Cmd+K palette (UDashboardSearch). Each source is a reactive builder of Nuxt UI CommandPaletteGroup arrays; the palette aggregates every registered source, in source order.
Signature
registerCommandPaletteSource(source: CommandPaletteSource): void
unregisterCommandPaletteSource(id: string): void
useCommandPaletteGroups(): { groups: ComputedRef<CommandPaletteGroup[]> }
Config shape
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable key; re-registering the same id replaces the entry, and unregister targets it. |
order | number | no | Position of this source's groups relative to other sources, ascending. Defaults to 100. |
groups | () => CommandPaletteGroup[] | yes | Builds the groups. Evaluated inside a computed, so any reactive state read here updates the palette automatically. Return [] when there is nothing to show. |
Prefix your group ids with your source id: group ids are global to the palette, and a collision silently merges the colliding groups' items under the first group's header. order applies to the initial (empty-search) view — while the user types, the palette reorders groups by match relevance.
An item may also carry a non-rendered searchText field (the COMMAND_PALETTE_SEARCH_TEXT_KEY constant): it joins the fuzzy-match corpus, weighted below label and suffix, and is stripped before the item renders — matching on it never replaces the breadcrumb shown. This is how the built-in navigation source matches on page descriptions; use it for keywords an item should answer to without displaying them.
When/where to call — From a .client plugin: the source carries callables (groups, item onSelect), which the SSR payload would drop. The consumer filters out any entry whose groups is not a function, so a stray server-side registration degrades to "commands not shown" rather than a crash. A source whose groups getter throws is skipped and logged, so it cannot take the other sources down with it.
Where it renders — DashboardSearch.vue feeds the aggregated groups to UDashboardSearch, opened from the sidebar search or Ctrl/Cmd+K.
Built-in sources — the DMS registers four; pick an order around them to position your groups:
| Source | Order | Groups it contributes |
|---|---|---|
Quick actions (cms-quick-actions) | 10 | Backend-registered quick actions, one group per category; the server sends only what the caller can run |
Personal pages (cms-personal-pages) | 20 | The user's favorite pages, and recently visited modules resuming at their last path |
Navigation (cms-navigation) | 40 | The whole nav tree flattened into breadcrumbed links (access-filtered; broader than the sidebar) |
Session (cms-session) | 50 | Profile / settings / switch-account, a language submenu, copy-current-URL, and logout |
Example
export default defineNuxtPlugin(() => {
const { tasks } = useMyTasks(); // any reactive state
const groups = computed(() => [
{
id: "my-module-tasks",
label: "Tasks",
items: tasks.value.map((task) => ({
label: task.title,
icon: "i-ph-check-square-light",
to: `/modules/tasks/${task.id}`,
})),
},
]);
registerCommandPaletteSource({
id: "my-module:tasks",
order: 30, // between personal pages (20) and navigation (40)
groups: () => groups.value,
});
});
Memoize your groups in your own computed (as above) and return it from groups — each source then only re-runs its builder when its own dependencies change. Labels coming from backend metadata may be $-prefixed i18n keys; resolve them with resolveI18nKey / getPluginI18n since useI18n is unavailable in plugin context.
App widgets (framed dock)
useAppWidgets().register(widget) adds a framed widget to the dock the dashboard anchors in the bottom-left corner of the page. The DMS owns the presentation: each widget renders as a low-opacity icon chip, stacked with the other widgets, and its body component unfolds into an animated card on hover or keyboard focus. Your module only declares metadata.
Signature
useAppWidgets(): {
widgets: Readonly<Ref<AppWidget[]>>;
register: (widget: AppWidget) => void;
unregister: (id: string) => void;
}
Config shape (AppWidget)
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable key; re-registering the same id replaces the entry, and unregister(id) removes it. |
icon | string | yes | Icon name (e.g. i-ph-git-branch-light) shown in the dock chip. |
label | string | yes | Chip tooltip and accessible name. A $-prefixed value resolves as an i18n key (useTranslation().processI18n). |
body | string | yes | The global component name rendered inside the unfolded card. |
order | number | no | Stack order, ascending from the bottom. Defaults to 0; registration order breaks ties. |
When/where to call — From an ordinary plugin (no .client needed): every field is a serializable string. Conditional visibility is the widget's own responsibility — register when it has something to show, unregister(id) otherwise.
Where it renders — AppWidgetsDock.vue, an absolute stack in the bottom-left of the dashboard content panel. The chip lights up on hover/focus and the body card slides out to the right; the other chips stay put.
Example
export default defineNuxtPlugin(() => {
useAppWidgets().register({
id: "my-module:status",
icon: "i-ph-pulse-light",
label: "$my_module.widget.label",
body: "MyModuleStatusCard", // a global component from your layer
order: 10,
});
});
Sidebar widgets (free)
useSidebarWidgets().register(widget) mounts a free component in the sidebar. Unlike the framed dock, the DMS adds no chrome here: the component renders as-is, stacked with the other sidebar widgets, and scrolls with the sidebar content. Where it lands is the widget's own choice (position); when it renders is a separate axis (module).
Signature
useSidebarWidgets(): {
widgets: Readonly<Ref<SidebarWidget[]>>;
register: (widget: SidebarWidget) => void;
unregister: (id: string) => void;
}
Config shape (SidebarWidget)
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable key; re-registering the same id replaces the entry, and unregister(id) removes it. |
component | string | yes | The global component name to mount. It receives a collapsed: boolean prop reflecting the sidebar's state. |
position | SidebarWidgetPosition | no | Where the widget lands: SidebarWidgetPosition.ABOVE_SEARCH_BAR or SidebarWidgetPosition.BELOW_SEARCH_BAR. Defaults to the historical placement — below for a module-scoped widget, above otherwise. |
order | number | no | Stack order, ascending from the top, among the widgets sharing the same position — widgets at different positions render at different spots, so order cannot interleave them. Defaults to 0. Ties are broken by the widget's current index in the registry, which is not the same as when it was registered: re-registering an id keeps its index (even if the new order puts it in a different tie group), and unregister then register sends it to the end. Give tied widgets distinct values when the stack has to be deterministic. |
module | string | no | When set, the widget only renders while browsing inside that module (/modules/<id>/**). Omit to render in the global sidebar only — the widget is then hidden while browsing inside a module. Scoping only picks the default position; a widget that declares one keeps it either way. |
The SidebarWidgetPosition value is auto-imported by the CMS layer, like useSidebarWidgets itself, so SidebarWidgetPosition.ABOVE_SEARCH_BAR needs no import statement. Its same-named type is not: annotating with it requires import type { SidebarWidgetPosition } from "#imports". Two helpers are exported alongside: resolveSidebarWidgetPosition(widget) returns the position a widget effectively renders at, defaults applied, and isSidebarWidgetVisible(widget, currentModuleId) answers whether it renders while browsing that module.
When/where to call — From an ordinary plugin (no .client needed): every field is serializable, so the widget renders during SSR. Module scoping is declarative via the module field; any other conditional visibility is the widget's own responsibility — render nothing when there is nothing to show, or unregister(id).
Where it renders — DashboardSidebar.vue, at the position the widget declares, in the sidebar's content area:
ABOVE_SEARCH_BAR— at the very top, directly below the logo and above the search button.BELOW_SEARCH_BAR— under the search button, and under the module title card as well while browsing inside a module, so a module widget reads as part of the module rather than of the chrome.
When it renders — independently of position, module gates visibility: a global widget (no module) only renders outside modules, and entering a module hides it; a module-scoped one only renders inside its own module. A widget stays registered when out of scope — it just never mounts — so registering once at boot is enough. Widgets coming from different layers keep a predictable stack by agreeing on order values: ordering spans the whole position, not each layer's own widgets.
Widgets keep rendering when the sidebar collapses to the icon rail: each one receives collapsed: boolean and adapts on its own (an icon-only variant, for example). Ignoring the prop is harmless, but the widget should at least not overflow the rail.
Example
export default defineNuxtPlugin(() => {
useSidebarWidgets().register({
id: "my-module:announcement",
component: "MyModuleAnnouncement", // a global component from your layer
position: SidebarWidgetPosition.ABOVE_SEARCH_BAR, // or BELOW_SEARCH_BAR
module: "my-module", // omit to render in the global sidebar only
order: 10,
});
});
<script setup lang="ts">
defineProps<{ collapsed?: boolean }>();
</script>
<template>
<div
class="rounded-md border border-default px-3 py-2"
:class="{ 'px-0 text-center': collapsed }"
>
<UIcon name="i-ph-megaphone" />
<span v-if="!collapsed">New release available!</span>
</div>
</template>
App overlays (free)
useAppOverlay().register(name) registers a global component by name that renders persistently on every page, with no chrome at all — the component is mounted naked and positions itself (typically position: fixed). Use it for floating action buttons, status bubbles, or anything the framed dock doesn't fit. Because it lives outside the routed page, it survives navigation.
Signature
useAppOverlay(): {
overlays: Readonly<Ref<string[]>>;
register: (name: string) => void;
unregister: (name: string) => void;
}
Config shape
| Argument | Type | Notes |
|---|---|---|
name | string | The global component name to mount. The component must be globally available (the layer's auto-imported components, or one you register globally). Registering the same name twice is a no-op. |
When/where to call — From an ordinary plugin: the payload is a string, so it is SSR-safe.
Where it renders — app.vue, inside UApp but outside NuxtPage. Each registered name is mounted via <component :is="overlay">, so the overlay is app-wide and persistent — and entirely responsible for its own placement.
Example — a floating support bubble that shows on every page, not just on your own module pages:
export default defineNuxtPlugin(() => {
useAppOverlay().register("MyModuleSupportBubble");
});
collapsed contract, and an overlay when the component must control its own placement and look.Footer links
registerFooterLink adds a link to the footer's link row (next to the copyright and the locale switcher).
Signature
registerFooterLink(link: FooterLink): void
useFooterLinks(): { links: ComputedRef<FooterLink[]> }
Config shape
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable key; re-registering the same id replaces the entry. |
label | string | yes | An i18n key; the footer renders it through t(). |
to | string | yes | Route or URL for the link target. |
order | number | yes | Sort key, ascending. Required — there is no default. |
When/where to call — From an ordinary plugin: the entry is plain data and SSR-safe. The registry is a module-level ref, so it does not even require the Nuxt context, but registering from a plugin keeps the layer's wiring in one place.
Where it renders — Footer.vue, as a NuxtLink per entry, sorted by order, with the label resolved via t(link.label).
Example
export default defineNuxtPlugin(() => {
registerFooterLink({
id: "my-module:docs",
label: "footer.docs",
to: "/docs",
order: 200,
});
});
Auth links
registerAuthLink adds a link below an auth page's form (for example, an extra link under the login form). Unlike the other registries, auth links are page-scoped: an entry declares which page it belongs to, and each page reads only its own links.
Signature
registerAuthLink(link: AuthLink): void
useAuthLinks(page: string): { links: ComputedRef<AuthLink[]> }
Config shape
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable key; re-registering the same id replaces the entry. |
page | string | yes | Which auth page the link belongs to. Must match the key the page passes to useAuthLinks(...) — e.g. the login page calls useAuthLinks("login"). |
label | string | yes | An i18n key, resolved through $t(). |
to | string | yes | Route or URL for the link target. |
order | number | yes | Sort key, ascending. Required — there is no default. |
When/where to call — From an ordinary plugin: the entry is plain data and SSR-safe. The registry is a module-level ref.
Where it renders — Below the target auth page's form. The login page renders its useAuthLinks("login") results, when non-empty, as a centered column of NuxtLinks under the submit button, each label resolved through $t() and sorted by order.
Example
export default defineNuxtPlugin(() => {
registerAuthLink({
id: "my-module:sso",
page: "login",
label: "auth.sso_hint",
to: "/auth/sso",
order: 100,
});
});
.acms cache — is covered in Frontend layer.