[data-reveal]{opacity:1!important;transform:none!important}
Extending The Dashboard

Dashboard chrome

The registries a frontend layer calls to add header buttons, command-palette groups, app widgets, sidebar widgets, global overlays, footer links, and auth-page links to the dashboard shell.

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

FieldTypeRequiredNotes
idstringyesStable key; re-registering the same id replaces the entry.
iconstringyesIcon name (e.g. i-ph-cloud-arrow-up-light). The button is icon-only.
labelstringyesUsed as the button title and aria-label (tooltip / a11y text).
onSelect() => voidyesRun on click.
ordernumbernoSort key, ascending. Defaults to 100.
isVisible() => booleannoRe-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 rendersDashboardHeader.vue, in the navbar #right slot, as a UButton with variant="ghost" and color="neutral". Visible entries are sorted by order.

Example

app/plugins/header-action.client.ts
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

FieldTypeRequiredNotes
idstringyesStable key; re-registering the same id replaces the entry, and unregister targets it.
ordernumbernoPosition of this source's groups relative to other sources, ascending. Defaults to 100.
groups() => CommandPaletteGroup[]yesBuilds 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 rendersDashboardSearch.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:

SourceOrderGroups it contributes
Quick actions (cms-quick-actions)10Backend-registered quick actions, one group per category; the server sends only what the caller can run
Personal pages (cms-personal-pages)20The user's favorite pages, and recently visited modules resuming at their last path
Navigation (cms-navigation)40The whole nav tree flattened into breadcrumbed links (access-filtered; broader than the sidebar)
Session (cms-session)50Profile / settings / switch-account, a language submenu, copy-current-URL, and logout

Example

app/plugins/command-palette-tasks.client.ts
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)

FieldTypeRequiredNotes
idstringyesStable key; re-registering the same id replaces the entry, and unregister(id) removes it.
iconstringyesIcon name (e.g. i-ph-git-branch-light) shown in the dock chip.
labelstringyesChip tooltip and accessible name. A $-prefixed value resolves as an i18n key (useTranslation().processI18n).
bodystringyesThe global component name rendered inside the unfolded card.
ordernumbernoStack 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 rendersAppWidgetsDock.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

app/plugins/app-widget.ts
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,
  });
});

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)

FieldTypeRequiredNotes
idstringyesStable key; re-registering the same id replaces the entry, and unregister(id) removes it.
componentstringyesThe global component name to mount. It receives a collapsed: boolean prop reflecting the sidebar's state.
positionSidebarWidgetPositionnoWhere 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.
ordernumbernoStack 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.
modulestringnoWhen 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 rendersDashboardSidebar.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

app/plugins/sidebar-widget.ts
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,
  });
});
app/components/MyModuleAnnouncement.vue
<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

ArgumentTypeNotes
namestringThe 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 rendersapp.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:

app/plugins/support-bubble.ts
export default defineNuxtPlugin(() => {
  useAppOverlay().register("MyModuleSupportBubble");
});
Three registries mount a component of yours; they differ in where it lands and who draws the chrome. Reach for an app widget when you want the dashboard's standard presentation (chip + unfold card) for free, a sidebar widget when the component belongs in the navigation column and can honor the collapsed contract, and an overlay when the component must control its own placement and look.

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

FieldTypeRequiredNotes
idstringyesStable key; re-registering the same id replaces the entry.
labelstringyesAn i18n key; the footer renders it through t().
tostringyesRoute or URL for the link target.
ordernumberyesSort 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 rendersFooter.vue, as a NuxtLink per entry, sorted by order, with the label resolved via t(link.label).

Example

app/plugins/footer-links.ts
export default defineNuxtPlugin(() => {
  registerFooterLink({
    id: "my-module:docs",
    label: "footer.docs",
    to: "/docs",
    order: 200,
  });
});

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

FieldTypeRequiredNotes
idstringyesStable key; re-registering the same id replaces the entry.
pagestringyesWhich auth page the link belongs to. Must match the key the page passes to useAuthLinks(...) — e.g. the login page calls useAuthLinks("login").
labelstringyesAn i18n key, resolved through $t().
tostringyesRoute or URL for the link target.
ordernumberyesSort 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

app/plugins/auth-links.ts
export default defineNuxtPlugin(() => {
  registerAuthLink({
    id: "my-module:sso",
    page: "login",
    label: "auth.sso_hint",
    to: "/auth/sso",
    order: 100,
  });
});
Authoring the layer that holds these plugins — folder layout, component prefixes for the names you register as app widgets, and the .acms cache — is covered in Frontend layer.