@xyz/navigation
v2.0.2
Published
Context-aware, type-safe navigation for multi-tenant Nuxt applications
Maintainers
Readme
@xyz/navigation
A flexible, context-aware navigation module for Nuxt 4 with zero performance overhead and zero boilerplate.
Features
- 🎯 Context-Aware Paths - Dynamic path resolution using templates (
{org},{slug},{username}) - 🔐 Role-Based Filtering - Show/hide items based on user roles
- 🚩 Feature Flags - Conditional navigation based on enabled features
- ⚡ Zero Performance Overhead - SSR-safe with reactive computed properties
- 🌍 Auto Translation - Automatic i18n integration with zero config
- 🎨 Normalized Structure - Consistent API for all navigation sections
- ✨ Active States - Automatic route matching and active states
- 🔧 Runtime Functions - Use composables directly in config
- 📘 Full TypeScript Support - Complete type safety and inference
Installation
pnpm add @xyz/navigation
# or
npm install @xyz/navigation
# or
yarn add @xyz/navigationQuick Start
1. Install & Enable
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@xyz/navigation'] // That's it!
})2. Create Unified Config
navigation.config.ts - Everything in one place:
export default {
// Module Options (optional, auto-detected)
translationResolver: () => useI18n().t, // Auto-detects $t if using @nuxtjs/i18n
// Navigation Sections
sections: {
// Flat section (auto-normalized)
main: [
{ label: 'nav.dashboard', to: '/app', icon: 'i-lucide-home' },
{ label: 'nav.projects', to: '/projects', icon: 'i-lucide-folder' }
],
// Nested section with header
organization: {
header: (ctx) => ({
title: ctx.activeOrganization?.name ?? 'Personal',
avatar: {
// ✅ Use composables in config - functions execute at runtime!
src: toAbsoluteUrl(ctx.activeOrganization?.logo || ''),
icon: 'i-lucide-building-2'
}
}),
items: [
{ label: 'nav.overview', to: '{org}' },
{ label: 'nav.team', to: '{org}/team', features: ['team'] }
],
children: [
{ label: 'nav.settings', to: '{org}/settings' }
]
}
}
}3. Use in Components
<script setup>
import navigationConfig from '~/navigation.config'
const { sections } = useNavigation(navigationConfig)
// sections.value = { main: {...}, organization: {...} }
</script>
<template>
<nav>
<!-- All sections have normalized structure -->
<UNavigationMenu :items="sections.value.main.items" />
<!-- Headers with runtime-resolved data -->
<div v-if="sections.value.organization.header">
<UAvatar :src="sections.value.organization.header.avatar.src" />
<h2>{{ sections.value.organization.header.title }}</h2>
</div>
<!-- Items with auto-computed active states -->
<UNavigationMenu :items="sections.value.organization.items" />
</nav>
</template>Note: Labels are auto-translated if using
@nuxtjs/i18nor a customtranslationResolver.
Note: Labels are auto-translated if using
@nuxtjs/i18nor a customtranslationResolver.
Unified Configuration
navigation.config.ts is your single source of truth for both module options and navigation data.
All-in-One Config
// navigation.config.ts
export default {
// Module Options (build-time, optional)
translationResolver: () => useTranslations().t,
contextProvider: () => ({
user: useAuthUser(),
activeOrganization: useActiveOrg(),
features: useFeatureFlags()
}),
templates: {
workspace: (ctx) => ctx.workspaceSlug || 'default'
},
// Navigation Sections (runtime)
sections: {
main: [...],
organization: {...}
}
}Override in nuxt.config.ts
nuxt.config.ts can override individual module options:
export default defineNuxtConfig({
navigation: {
translationResolver: () => customResolver() // Overrides navigation.config.ts
}
})Priority: nuxt.config.ts > navigation.config.ts module options > navigation.config.ts sections
Core Concepts
Section Normalization
All sections return a consistent structure:
{
items?: NavigationMenuItem[] // Main navigation items
header?: SectionHeader // Optional header with title, avatar
children?: NavigationMenuItem[] // Sub-navigation items
footer?: NavigationMenuItem[] // Footer items
}Flat sections (arrays) are auto-wrapped:
// Config
sections: {
main: [{ label: 'Dashboard', to: '/app' }]
}
// Result
sections.value.main = {
items: [{ label: 'Dashboard', to: '/app', active: true }],
header: undefined,
children: [],
footer: []
}Runtime Functions in Config
Config functions execute at runtime, so composables work:
export default {
sections: {
organization: {
header: (ctx) => ({
title: ctx.activeOrganization?.name,
avatar: {
// ✅ Use composables directly!
src: toAbsoluteUrl(ctx.activeOrganization?.logo || '')
}
}),
items: [...]
}
}
}Automatic Translation
Zero config with @nuxtjs/i18n:
// navigation.config.ts - use translation keys
export default {
sections: {
main: [
{ label: 'nav.dashboard', to: '/app' }, // Auto-translated!
{ label: 'nav.projects', to: '/projects' }
]
}
}
// Component - translations applied automatically
const { sections } = useNavigation(config)Custom translation resolver:
// nuxt.config.ts
export default defineNuxtConfig({
navigation: {
translationResolver: () => {
const { t } = useTranslations()
return t
}
}
})Automatic Active States
Navigation items include active boolean based on current route:
{
label: 'Blog',
to: '/blog',
exact: false, // Active for /blog and /blog/*
active: true // ✅ Auto-computed
}Use in templates:
<template>
<NuxtLink
v-for="item in sections.value.main.items"
:to="item.to"
:class="{ 'font-bold': item.active }"
>
{{ item.label }}
</NuxtLink>
</template>Template Variables
Built-in template variables for dynamic paths:
| Template | Resolves To | Example |
|----------|-------------|---------|
| {org} | /app (personal) or /app/:slug (organization) | {org}/projects → /app/acme/projects |
| {slug} | Current organization slug | {slug} → acme |
| {username} | Current user's name or email | /u/{username} → /u/john |
| {user.id} | Current user ID | /users/{user.id} → /users/123 |
Custom Templates
// nuxt.config.ts
export default defineNuxtConfig({
navigation: {
templates: {
workspace: (ctx) => ctx.workspaceSlug || 'default',
tenant: (ctx) => ctx.activeTenant?.id || ''
}
}
})
// Use in config
{ label: 'Workspace', to: '/w/{workspace}/dashboard' }Advanced Features
Feature Flags
{
label: 'Analytics',
to: '/analytics',
features: ['analytics'] // Only shown if analytics feature enabled
}Provide features via context:
const { sections } = useNavigation(config, {
context: {
features: { analytics: true, team: false }
}
})Role-Based Filtering
{
label: 'Admin Panel',
to: '/admin',
roles: ['admin', 'owner'] // Only for admins/owners
}Configure role hierarchy:
// nuxt.config.ts
export default defineNuxtConfig({
navigation: {
config: {
roles: {
hierarchy: ['viewer', 'member', 'admin', 'owner'],
resolver: (user) => user?.role || 'viewer'
}
}
}
})Nested Navigation
{
label: 'Settings',
to: '/settings',
children: [
{ label: 'General', to: '/settings/general' },
{ label: 'Billing', to: '/settings/billing', roles: ['admin'] }
]
}Custom Conditions
{
label: 'Upgrade',
to: '/upgrade',
condition: (ctx) => !ctx.user?.isPremium // Only for free users
}API Reference
useNavigation(config, options?)
Main composable for navigation.
Parameters:
config:NavigationConfig- Navigation configuration objectoptions?:UseNavigationOptions- Optional configuration
Returns:
{
sections: ComputedRef<Record<string, ProcessedSection>>
context: ComputedRef<NavigationContext>
refresh: () => void
}Options:
interface UseNavigationOptions {
// Custom context provider
context?: NavigationContext | (() => NavigationContext)
// Reactive updates (default: true)
reactive?: boolean
// Custom template resolvers
templates?: Record<string, (ctx: NavigationContext) => string>
// Override global translation function
translationFn?: (key: string) => string
}Navigation Item Config
interface NavigationItemConfig {
label: string // Label (or translation key)
to?: string // Path (supports templates)
icon?: string // Icon name
exact?: boolean // Exact route matching (default: true)
// Filtering
features?: string[] // Required feature flags
roles?: string[] // Required user roles
condition?: (ctx: NavigationContext) => boolean // Custom condition
// Nested navigation
children?: NavigationItemConfig[]
// Custom handlers
onSelect?: (item: NavigationItemConfig) => void
// Divider
divider?: boolean
// Custom metadata
badge?: string | number
[key: string]: any // Additional custom fields
}Section Header
interface SectionHeader {
title?: string
subtitle?: string
avatar?: {
src?: string | null
icon?: string
fallback?: string
}
}Processed Section
All sections return this normalized structure:
interface ProcessedSection {
items?: NavigationMenuItem[] // With active states
header?: SectionHeader
children?: NavigationMenuItem[]
footer?: NavigationMenuItem[]
}Complete Example
// navigation.config.ts
export default {
sections: {
// App navigation (flat)
app: [
{
label: 'nav.dashboard',
to: '/app',
icon: 'i-lucide-home',
exact: true
},
{
label: 'nav.projects',
to: '/app/projects',
icon: 'i-lucide-folder',
exact: false // Active for /app/projects/*
}
],
// Organization navigation (nested)
organization: {
header: (ctx) => ({
title: ctx.activeOrganization?.name ?? 'Personal',
subtitle: ctx.activeOrganization?.slug,
avatar: {
src: useImageUrl().toAbsoluteUrl(
ctx.activeOrganization?.logo || ''
),
icon: 'i-lucide-building-2',
fallback: ctx.activeOrganization?.name?.charAt(0)
}
}),
items: [
{
label: 'nav.overview',
to: '{org}',
icon: 'i-lucide-layout-dashboard'
},
{
label: 'nav.team',
to: '{org}/team',
icon: 'i-lucide-users',
features: ['team']
},
{
label: 'nav.analytics',
to: '{org}/analytics',
icon: 'i-lucide-bar-chart',
features: ['analytics'],
roles: ['admin', 'owner']
}
],
children: [
{
label: 'nav.settings',
to: '{org}/settings',
icon: 'i-lucide-settings',
children: [
{ label: 'nav.settings.general', to: '{org}/settings/general' },
{ label: 'nav.settings.billing', to: '{org}/settings/billing', roles: ['owner'] }
]
}
],
footer: [
{ label: 'nav.help', to: '/help', icon: 'i-lucide-help-circle' },
{ label: 'nav.docs', to: '/docs', icon: 'i-lucide-book' }
]
},
// Profile navigation
profile: [
{ label: 'nav.profile.account', to: '/profile', icon: 'i-lucide-user' },
{ label: 'nav.profile.preferences', to: '/preferences', icon: 'i-lucide-sliders' },
{ divider: true },
{ label: 'nav.profile.logout', icon: 'i-lucide-log-out', onSelect: () => logout() }
]
}
}Module Configuration
// nuxt.config.ts
export default defineNuxtConfig({
navigation: {
// Translation resolver (runtime)
translationResolver: () => {
const { t } = useTranslations()
return t
},
// Custom templates
templates: {
workspace: (ctx) => ctx.workspaceSlug || 'default'
},
// Context provider
contextProvider: (nuxtApp) => ({
user: nuxtApp.$auth?.user,
activeOrganization: nuxtApp.$org?.active,
features: nuxtApp.$features?.all()
}),
// Role configuration
config: {
roles: {
hierarchy: ['viewer', 'member', 'admin', 'owner'],
resolver: (user) => user?.role || 'viewer'
}
}
}
})TypeScript
Full type safety with inference:
import type { NavigationConfig, NavigationMenuItem } from '@xyz/navigation'
const config: NavigationConfig = {
sections: {
main: [...] // Fully typed
}
}
const { sections } = useNavigation(config)
sections.value.main.items // NavigationMenuItem[]Migration Guide
From v1.0 to v1.2
All sections now return normalized structure:
// v1.0
sections.value.main // NavigationMenuItem[]
sections.value.org // { items, header, children, footer }
// v1.2 (normalized)
sections.value.main.items // NavigationMenuItem[]
sections.value.org.items // NavigationMenuItem[]Both flat and nested sections now have consistent API.
License
MIT
Contributing
Contributions welcome! Please open an issue or PR.
