Menu
Menu lets any module add a link to your app's navigation. Install Events and its pages appear in the admin sidebar. Install Auth and a sign-out item appears in the account menu. Neither module imports the other. Use it for headers, sidebars and account menus that modules should be able to fill. For a list of links only your app sets, a plain array in a component is enough.
The package is @frigear-nu/menu. Each menu has a name, such as main or admin.nav. Items come from modules, from
Nuxt Content or from both, and your app reads the merged list. Menu is one of the
registries modules add to. It only returns data, so your app draws the menu its own way.
What you get
- Composables and helpers (on by default):
useMenu('main')in the app anduseMenu(event, 'main')on the server return the merged, sorted items. - Module helpers (on by default):
extendMenuandregisterMenuSourceadd items from a module. - Content menus (opt-in): items read from a Nuxt Content collection, such as the website's navigation.
- Translated labels (opt-in, with
@nuxtjs/i18n): items carry i18n keys.
@frigear-nu/module-kit.You add: nothing else.Optional:@nuxt/content (required for a menu that reads content) and @nuxtjs/i18n.Quick setup
Most modules declare Menu as an optional dependency; Admin installs it. Install it when your app should draw their items.
Install Menu
pnpm add @frigear-nu/menu
Add it to nuxt.config.ts
export default defineNuxtConfig({
modules: ['@frigear-nu/menu'],
})
Draw a menu
<script setup lang="ts">
const { data: items } = useMenu('main')
</script>
<template>
<nav>
<NuxtLink v-for="item in items" :key="item.id" :to="item.to">
{{ item.label }}
</NuxtLink>
</nav>
</template>
extendMenu(nuxt, 'main', …), its link appears in your <nav>.Options
Options go under the menus key.
| Option | Type | Default | Purpose |
|---|---|---|---|
content.<menu> | boolean | { collection?: string, field?: string } | none | Reads that menu's items from Nuxt Content. |
content.<menu>. true reads the navigation field of the config collection, as the
base layer defines it. An object picks another collection or field. Without
@nuxt/content installed, the build fails.
export default defineNuxtConfig({
menus: {
content: {
main: true,
dashboard: { collection: 'dashboard', field: 'tiles' },
},
},
})
With @nuxtjs/i18n and locales, content items come from the per-locale collection: config becomes config_en. The
base layer uses the same convention.
Server
useMenu(event, path) returns a menu in a server route. It is auto-imported in server code.
useMenu(event: H3Event, path: string, options?: { locale?: string }): Promise<MenuItem[]>
export default defineEventHandler(event => useMenu(event, 'dashboard.*'))
It merges the same items as the app composable, and paths work the same way. It differs in three ways:
- Labels are not translated:
labelKeyanddescriptionKeyare returned untouched. - Content comes from the collection without a locale, unless you pass
{ locale }. - Runtime menu files run once per server process, not per request. So they should read runtime config, not
request data, and import
extendMenuItemfrom@frigear-nu/menu/utilsexplicitly.
App
Read a menu
useMenu(path) returns the items of a menu, in the shape of
useAsyncData. It is auto-imported and re-evaluates when
the locale changes.
useMenu(path: string): AsyncData<MenuItem[], NuxtError | undefined> // { data, pending, error, refresh }
const { data: items } = useMenu('main') // the root group of `main`
const { data: more } = useMenu('dashboard.more') // group `more` of `dashboard`
const { data: all } = useMenu('dashboard.*') // every group of `dashboard`, flattened
const { data: tree } = useMenu('dashboard.account.*') // `account` and groups nested under it, not siblings
A path is split on its first dot only: a.b.c is menu a, group b.c. A path without a dot is the menu's root
group. A trailing .* is a wildcard, and works only when reading.
Items are sorted by order, lowest first, and items without one come last. Ties keep their order: content items
first, then module items in the order they were added.
Items
type MenuItem = {
id?: string // stable key; default: `to`, else a slug of `label`
label: string
labelKey?: string // i18n key; falls back to `label`
description?: string
descriptionKey?: string
icon?: string
to?: string
external?: boolean
method?: 'post' // an action, not a link: the view posts to `to`, then reloads the home page
permission?: string // `resource:action`: shown only to viewers who hold it
tags?: string[]
order?: number // lower first
group?: string // set from the registration path; an explicit value wins
meta?: Record<string, unknown>
}
| Field | Notes |
|---|---|
id | Derived from the untranslated label, so it is the same in every locale. A duplicate in one batch warns in development. |
label, description | The same shape as the navigation entries of the base layer's config collection. |
method | AccountMenu sends POST to to, then reloads the home page. Auth's sign-out item uses it. |
labelKey, descriptionKey | Translated in the active locale. Without @nuxtjs/i18n, or without a message, the plain text is used. |
description and icon are optional: your app may draw them or not.
Translate items
Items from code carry i18n keys. Ship the messages in your module's runtime/i18n/lang/*.json and register them with
registerLocales (see Module Kit). Content items use their per-locale collection
instead.
extendMenuItem('account.dropdown', {
id: 'logout',
label: 'Logout',
labelKey: 'account.menu.logout.label',
description: 'Sign out of your account.',
descriptionKey: 'account.menu.logout.description',
})
Add items from a module
A module adds items in one of two ways, and the order between modules does not matter: items are merged once every module has set up. This is the menu half of the extension points.
Items known at build time. Call extendMenu in the module's setup():
import { extendMenu } from '@frigear-nu/menu/utils'
export default defineNuxtModule({
meta: { name: '@frigear-nu/example' },
setup(options, nuxt) {
extendMenu(nuxt, 'dashboard.more', [
{ label: 'Billing', description: 'Manage your subscription', to: '/billing' },
])
},
})
Items that need runtime context, such as useRuntimeConfig. Register a runtime menu file:
import { createResolver, defineNuxtModule } from '@nuxt/kit'
import { registerMenuSource } from '@frigear-nu/menu/utils'
export default defineNuxtModule({
meta: { name: '@frigear-nu/example' },
setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
registerMenuSource(nuxt, { file: resolver.resolve('./runtime/menus'), menu: 'dashboard' })
},
})
// runtime/menus.ts
import { defineMenuSource, extendMenuItem } from '@frigear-nu/menu/utils'
export default defineMenuSource((items) => {
const config = useRuntimeConfig().public.example
// Pushed into the `dashboard` menu's root group.
if (config?.enabled) items.push({ label: 'Example', to: '/example' })
// Pushed into a named group instead, created if missing.
extendMenuItem('dashboard.more', { label: 'Security', to: '/security' })
})
extendMenuItem before the first await of the file. It finds the current app through the Nuxt context, which
is lost after an await. It does not work in a module's setup(): use extendMenu there.A module that should not depend on Menu registers its file with addOptionalMenuSource from
Module Kit. It skips the registration when Menu is not installed. addMenuSource
registers it always.
Utils
@frigear-nu/menu/utils is framework-agnostic.
| Export | Where it runs | Purpose |
|---|---|---|
extendMenu(nuxt, path, items) | Module setup() | Adds static items to one exact group. |
registerMenuSource(nuxt, { file, menu }) | Module setup() | Registers a runtime menu file. file must be a resolved path. |
defineMenuSource(fn) | Runtime menu file | A typed helper. fn(items) receives the menu's root group. |
extendMenuItem(path, items) | Nuxt app (auto-imported) | Adds items to a group from a runtime menu file or app code. |
defineMenuItem(item) | Anywhere | A typed helper for an item. |
parseMenuPath(path), deriveMenuItemId(item) | Anywhere | Path parsing and id derivation. |
The types MenuItem, MenuSource, RegisterMenuSourceOptions and ParsedMenuPath come from the same path.
Hooks
Module authors and your app can listen to these hooks.
| Hook | Where | When | Payload |
|---|---|---|---|
menus:extend | Nuxt (build) | extendMenu adds items | (menu: string, items: MenuItem[]) |
menus:resolved | Nuxt (build) | Once, after every module has set up | (menus: Record<string, Record<string, MenuItem[]>>): menu, then group, then items |
menus:filter | Nuxt app | useMenu resolved a menu's items, before menus:visible | { menu, path, items: MenuItem[] }; reassign or filter ctx.items |
menus:visible | Nuxt app | useMenu resolved a menu's items | (menu: string) |
menus:resolved holds only the items added at build time. Items from runtime menu files and Nuxt Content resolve
when the page renders.
Hide items per viewer
An item with a permission is shown only to viewers who hold it. Menu itself never interprets the field. It calls
menus:filter once per resolved menu, and a listener drops what the viewer should not see. With
Authorization installed, a listener is already registered: it keeps an item when it
has no permission or when can(item.permission) is true, children included.
extendMenu(nuxt, 'admin.nav', [
{ label: 'Forms', to: '/admin/forms', icon: 'i-lucide-clipboard-list', permission: 'forms:manage' },
])
Without a listener, every item is shown. To filter by something else, add your own:
import { filterMenuItems } from '@frigear-nu/menu/utils'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hook('menus:filter', (ctx) => {
ctx.items = filterMenuItems(ctx.items, item => item.meta?.beta !== true)
})
})
The resolved menu is cached under a key that includes an access version. Authorization raises the version whenever the viewer's access changes (sign-in, sign-out, a role change), so menus filter again without a reload.
authorize on the server.Next steps
- Add an admin page – add a page with its entry in the admin sidebar.
- Extension points – how menus, widgets and hooks let modules extend each other.
- Widget – add components to a page the way Menu adds links.