Interface

Menu

Lets modules add links to your app's navigation, such as the admin sidebar, without importing each other.

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 and useMenu(event, 'main') on the server return the merged, sorted items.
  • Module helpers (on by default): extendMenu and registerMenuSource add 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.
ServerClientMobile
Installed for you:@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

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>
What you should see: after a module calls extendMenu(nuxt, 'main', …), its link appears in your <nav>.

Options

Options go under the menus key.

OptionTypeDefaultPurpose
content.<menu>boolean | { collection?: string, field?: string }noneReads 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.

nuxt.config.ts
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: labelKey and descriptionKey are 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 extendMenuItem from @frigear-nu/menu/utils explicitly.

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>
}
FieldNotes
idDerived from the untranslated label, so it is the same in every locale. A duplicate in one batch warns in development.
label, descriptionThe same shape as the navigation entries of the base layer's config collection.
methodAccountMenu sends POST to to, then reloads the home page. Auth's sign-out item uses it.
labelKey, descriptionKeyTranslated 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' })
})
Call 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.

ExportWhere it runsPurpose
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 fileA 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)AnywhereA typed helper for an item.
parseMenuPath(path), deriveMenuItemId(item)AnywherePath 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.

HookWhereWhenPayload
menus:extendNuxt (build)extendMenu adds items(menu: string, items: MenuItem[])
menus:resolvedNuxt (build)Once, after every module has set up(menus: Record<string, Record<string, MenuItem[]>>): menu, then group, then items
menus:filterNuxt appuseMenu resolved a menu's items, before menus:visible{ menu, path, items: MenuItem[] }; reassign or filter ctx.items
menus:visibleNuxt appuseMenu 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.

module.ts
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:

app/plugins/menu.ts
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.

Filtering only hides links. The route behind an item must still call 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.
Copyright © 2026