Skip to content

Navigation ​

The menu is a single array. Every shell renders it, and two gates filter it.

Open in demo

The tree ​

src/layouts/navItems.ts exports appNavItems, a NavItem[]. One interface covers three shapes: a leaf has to, a group has children, and a section has section: true.

ts
import type { NavItem } from '@/layouts/types'
import { Contact, LayoutDashboard, ShoppingCart } from 'lucide-vue-next'

export const appNavItems: NavItem[] = [
  { label: 'Main', labelKey: 'nav.sections.main', section: true },
  {
    label: 'Dashboards',
    labelKey: 'nav.dashboards',
    icon: LayoutDashboard,
    badge: 13,
    children: [
      { label: 'CRM', labelKey: 'nav.crm', to: '/dashboards/crm', icon: Contact },
      { label: 'eCommerce', labelKey: 'nav.ecommerce', to: '/dashboards/ecommerce', icon: ShoppingCart },
    ],
  },
]
KeyTypeDescription
labelstringThe English fallback. Always set it: it is what renders when labelKey is missing from the active language, so a gap degrades to readable text instead of a raw key.
labelKeystringA translation key, preferred wherever it resolves. A key used here must exist in every locale file.
tostringThe route. Makes the item a link.
iconComponentA lucide icon.
badgestring | numberA trailing count or short status.
childrenNavItem[]Nested items. Recursive; a child may be a group.
sectionbooleanA caption rather than an entry. Sections are flat markers, not containers — the items after one belong to it visually.
disabledbooleanGrayed out and not interactive, with no to. A link to an unregistered path makes the router warn, which is what this avoids.
permissionstring"<action> <subject>". Hidden unless the ability grants it. On a group it hides the subtree.
featurekeyof featuresHidden while that config feature is off, with the same subtree rule.

Three levels is the supported depth. The renderer recurses further, but the tokens only define three indent stops.

The two gates ​

ts
import { filterNavByPermission } from '@/layouts/navUtils'
import { useNavPermission } from '@/layouts/useNavPermission'

const { can, isFeatureEnabled } = useNavPermission()
const items = computed(() => filterNavByPermission(appNavItems, can, isFeatureEnabled))

An item needs both gates to pass. Denying a group hides its subtree, and a group left empty by filtering is dropped rather than kept as a toggle that opens onto nothing.

filterNavByPermission takes both predicates as arguments, so it stays pure and testable.

The two gates differ in one way, deliberately: a signed-out visitor passes every permission check and sees the whole menu, because a page missing from a guest's menu reads as a page that was never built. feature applies to everyone — a switched-off feature is off for guests too, with no "sign in to see it" story behind it. Nothing is unlocked either way; the route guard still bounces a gated route to the sign-in page.

Which item is active ​

Most-specific-wins needs whole-tree knowledge, and a sidebar item can only see itself. So the active leaf is resolved once at the sidebar root and injected down under the ACTIVE_LEAF_PATH key. Injection still reaches the collapsed rail's flyout, because Teleport moves DOM, not the provide and inject hierarchy.

navUtils exports the pieces: isPathActive(routePath, to), containsActivePath(item, routePath) and findActiveLeafPath(items, routePath).

SmartBreadcrumb builds the trail from the matched route records. Every route carrying a meta.title becomes a crumb, and the last one is plain text rather than a link.

json
{
  "path": "/products/:id/edit",
  "name": "products-edit",
  "meta": { "title": "products.edit.title", "breadcrumb": "common.crumb.edit" }
}

meta.breadcrumb overrides the label. It takes a string, or a function that derives the label from the route, which is how a detail page shows the record's own name:

ts
breadcrumb: route => fetchProduct(route.params.id).then(p => p.name)

Both forms are resolved through uiText, so a key translates and a literal renders as itself. meta.hideBreadcrumb drops the trail entirely on a route.

Pinned pages ​

ts
import { usePinnedPages } from '@shared/composables/usePinnedPages'

const { pinned, pin, unpin, isPinned, reorder } = usePinnedPages()

Persisted under sa-pinned as singleton state, so the sidebar list and the page header's pin toggle stay in step and survive a reload. A page's identity is its route path.

PinnedMenu is the sidebar list itself. It takes no props: it reads the same composable, and dragging a row reorders it.

The command palette ​

Every named route with a meta.title is registered as a command at boot, which is why Ctrl + K can reach any page without a hand-maintained list. Routes with path parameters are skipped: they need an id that nothing has at registration time.

A module adds its own commands from its index.ts:

ts
import router from '@app/router'
import { useCommandRegistry } from '@shared/composables/useCommandRegistry'

useCommandRegistry().register([
  { id: 'products.list', title: 'products.title', group: 'pages', handler: () => { void router.push('/products') } },
  { id: 'products.new', title: 'products.new', group: 'actions', handler: () => { void router.push('/products/new') } },
])
KeyTypeDescription
idstringUnique. Registering the same id again replaces the command.
titlestringA translation key, not a word. A module generated in a nine-language template must not ship English into all nine.
group'pages' | 'actions' | 'recent' | 'settings'Which section it appears under.
iconComponentOptional lucide icon.
shortcutstringA display-only hint, such as 'g d'.
keywordsstring[]Widens the fuzzy search beyond the title.
featurekeyof featuresHides the command while that feature is off. Filtered at render, not at registration, because registration runs once at boot and the flag is live.
handler() => void | Promise<void>What running it does.

The last eight commands the reader ran are remembered under sa-recent-commands.

Adding to the menu ​

The generator adds an entry for you and tells you where:

ts
{ label: 'Posts', labelKey: 'post_module.title', to: '/posts', icon: Blocks },

It lands at the end with a generic icon. Move it into the section it belongs to and swap the icon.

Where to go next ​

  • Layouts — the shells that render this tree.
  • Permissions — what permission checks against.