Examples here import from
@spree/dashboard, which re-exports the framework and the design system for host applications. A distributed plugin imports @spree/dashboard-core and @spree/dashboard-ui directly instead — it extends the shell rather than shipping it. See plugin overview.nav) and settings sub-shell (settingsNav). Both are module singletons exposed from @spree/dashboard-core and consumed by <AppSidebar> and <SettingsSidebar> via useSyncExternalStore, so late registration re-renders the sidebars automatically.
Main sidebar (nav)
Add an entry
position controls ordering — built-ins run 50–600 (Getting Started 50 when shown, Home 100, Orders 200, Products 300, Inventory 350, Customers 400, Sellers 450, Loyalty 475, Promotions 500, Reports 600), leaving gaps to slot into. The default is 100. Keys must be unique, including against the built-in keys (getting-started, home, orders, products, inventory, customers, sellers, loyalty, promotions, reports, settings) — duplicates throw at boot.
Add via defineDashboardPlugin
The facade groups multiple registry calls:
Nest children under a parent
subject independently — the parent doesn’t auto-gate them.
Nest under an existing (built-in) parent
To add an item inside a menu you don’t own — e.g. a “Brands” page under the built-in Products menu — useaddChild, which preserves the parent’s existing children. (nav.update('products', { children: [...] }) would replace them, dropping the built-ins.)
updateChild / removeChild mutate an existing one). Built-in parents (products, orders, customers, promotions) register during app bootstrap, before any plugin runs, so they’re always available to nest into.
Insert relative to an existing entry
position ± 1 unless you specify your own.
Modify or remove
Imperatively:defineDashboardPlugin — the nav object form adds, removes, and patches in one config (built-in entries included; they register before any plugin runs):
nav: [...]) remains the shorthand for { add: [...] }. The object form also takes addChildren (see Nest under an existing parent). remove of an unknown key is a no-op; update of an unknown key throws. settingsNav accepts the same object form (minus addChildren — settings entries are flat).
Bottom-pinned entries
section: 'bottom' pins to the sidebar footer (where “Settings” lives by default):
Conditional visibility and badges
Beyond permission gating withsubject, a top-level entry can hide itself based on app state (if) and render a component after its label (badge):
if combines with subject — both must pass.
Settings sub-nav (settingsNav)
The settings page has its own sub-shell sidebar with grouped entries (Store, Localization, Team & Access, …). Entries cluster under groups defined separately.
Register a group
Add entries to a group
comingSoon badge
Disabled entries with a “Soon” badge — useful for staging the rollout:
Permission gating
subject on a nav entry checks permissions.can('read', subject) — when it fails, the sidebar item is hidden. This is UX, not authorization. The backend still enforces CanCanCan via authorize! on every API call. Hiding the link is a hint, not a security boundary.
Order of operations
Nav registrations run at module-load time. The sidebar reads viauseSyncExternalStore, so:
- Registering before the sidebar mounts: works (the snapshot picks it up on first render)
- Registering after the sidebar mounts: also works (the store notifies subscribers)
i18n.t('admin.foo.label') for label, your translation bundle must be registered first. The framework’s own translations load before src/plugins.ts runs, so the rule reduces to: put your i18n.addResourceBundle(...) call at the top of plugins.ts, above the registrations that use it. See Translations.
Reference
NavEntry— the full shape including types forchildren,position,subject,sectionSettingsNavEntry— settings sub-nav types

