Navigation
The menu is a single array. Every shell renders it, and two gates filter it.
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.
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 },
],
},
]| Key | Type | Description |
|---|---|---|
label | string | The 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. |
labelKey | string | A translation key, preferred wherever it resolves. A key used here must exist in every locale file. |
to | string | The route. Makes the item a link. |
icon | Component | A lucide icon. |
badge | string | number | A trailing count or short status. |
children | NavItem[] | Nested items. Recursive; a child may be a group. |
section | boolean | A caption rather than an entry. Sections are flat markers, not containers — the items after one belong to it visually. |
disabled | boolean | Grayed out and not interactive, with no to. A link to an unregistered path makes the router warn, which is what this avoids. |
permission | string | "<action> <subject>". Hidden unless the ability grants it. On a group it hides the subtree. |
feature | keyof features | Hidden 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
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).
Breadcrumbs
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.
{
"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:
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
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:
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') } },
])| Key | Type | Description |
|---|---|---|
id | string | Unique. Registering the same id again replaces the command. |
title | string | A 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. |
icon | Component | Optional lucide icon. |
shortcut | string | A display-only hint, such as 'g d'. |
keywords | string[] | Widens the fuzzy search beyond the title. |
feature | keyof features | Hides 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:
{ 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
permissionchecks against.