Concepts

Extension points

How a module adds menu items, dashboard widgets, tabs, admin pages, permissions, roles, scopes and MCP tools to other modules without importing them.

Your module has a Billing page. It should appear in the account menu, put a widget on the admin dashboard and offer a tool to AI agents, without you editing the Account, Admin or MCP modules. After this page you can decide which extension point to use for each of those, and where its full reference lives.

For: module authors. App developers can read Override a module's widget and Hooks.

Which one do I use?

You want toUseRead
Add a link to a menu, such as the account menuextendMenuMenus
Add a link to the admin sidebar or a card to the admin dashboardextendAdminNav, extendAdminDashboardAdmin navigation and dashboard
Show a component in a named slot, such as the account overviewextendWidgetsWidget areas and tabs
Add a tab to a detail page another module ownsextendTabsWidget areas and tabs
Let the host app override who may do somethingauthorization:beforeHooks
Say what your routes need, and who holds it by defaultaddPermission, addRoleAuth extension points
Let users give a token access to your routesaddScopeAuth extension points
Offer tools to AI agentsaddMcpDefinitionsMCP tools
Load your data in one request with other modules' dataregisterBootstrapProviderBootstrap providers
Add a column to another module's tableextendDatabaseTableDatabase extensions

Terms

  • Extension point. A place where one module adds things to another without importing it, either a registry or a hook.
  • Registry. A build-time list that modules add entries to, such as menus, widgets, tabs, permissions, roles, scopes and Model Context Protocol (MCP) tools.
  • Hook. A runtime hook (Nitro or app) that a module calls and that listeners in the host app or other modules answer.
  • Widget area. A named slot where the Widget module renders the components modules add.

How it works

Extension points come in two kinds:

KindWhen it runsExample
RegistryIn a module's setup(), collected once every module is doneextendMenu, extendWidgets, addPermission, addMcpDefinitions
HookAt run time, a Nitro or app hook answered by the host app or another moduleauthorization:before, auth:email, widgets:filter

Registration order between modules never matters: items are merged after modules:done.

Nuxt build hooks, such as auth:resolvers and widgets:resolved, run at build time and are not extension points. Module Kit has the build-time helpers for module authors.

Registries

Most registries are built on defineVirtualRegistry from @frigear-nu/module-kit/utils.

  1. The module that owns the registry calls it from its setup().
  2. Other modules add entries during their own setup(), through the owner's helper.
  3. Once every module is done, the entries are bundled into one virtual module that the runtime imports.

There is no scanning at run time, which keeps it working on Cloudflare Workers.

Registries and menus has the signature and options of defineVirtualRegistry, including its app and server targets.

Hooks: ask the host app

A hook is a question a module asks and the host app answers. Access control has two Nitro hooks, both answered by your host app (or another module), and three app hooks that filter what the interface shows:

HookFired byThe listener decides
authorization:beforeAuth, on every authorize, allows and canGrantScopeAllow or deny any permission, after the credential checks. Context: { event, principal, user, permission, record, allow(), deny() }.
authorization:rolesAuth, while it resolves a user's rolesRoles to add or remove, such as an environment allowlist mapped to admin. Context: { event, user, roles }.
menus:filterMenu, when it resolves a menuWhich items the viewer sees (item.permission). Authorization answers it with can().
widgets:filter, tabs:filterThe widget componentsWhich widgets and tabs the viewer sees (permission on the entry).
oauth:application:accessOAuth ServerWho may sign in to a given OAuth application.

authorization:before is the single override. Results are collected across listeners: any deny() or a listener that throws denies; otherwise any allow() allows. It runs after the credential checks, so it can never give a token a permission it was not issued, nor a token a sessionOnly permission. A listener identifies the caller from ctx.user, never from the session on ctx.event. The filter hooks only hide things; the server still checks.

Access control explains both Nitro hooks. At build time, auth:access-compiled hands the compiled permissions, roles and scopes to any module that wants to read them, for example to list them in documentation or an admin page.

Each module used to have its own authorize hook. Those are removed with no shim: a listener on them never runs. In development a plugin warns when one still has listeners.
Removed hookReplace it with
events:authorize, forms:authorize, payments:authorize, oauth:authorize, authorization:authorize, users:admin:authorizeauthorization:before, or a policy (definePolicy) on that module's permissions
users:admin:rolesRoles in authorization.roles
authorization:roleauthorization:roles
auth:user-permissionsRoles, or authorization:before
auth:scope-availableauthorization:before; minting goes through canGrantScope
oauth:scope:staffRoles, or authorization:before; consent goes through canGrantScope
oauth:user:rolesauthorization:roles; an application's allowedRoles resolves through the role provider
The migration guide has a before and after for each.

Menu holds named menus, such as main, dashboard and account. A menu path is split on its first dot: dashboard.more is group more of menu dashboard. Items from Nuxt Content, from modules and from runtime sources are merged and sorted by order.

An item may name a permission. The item is hidden for a viewer who lacks it, and the menu is resolved again on sign-in and sign-out. The route behind the item checks the permission itself.

A module adds static items at build time:

import { extendMenu } from '@frigear-nu/menu/utils'

extendMenu(nuxt, 'dashboard.more', [
  { label: 'Billing', description: 'Manage your subscription', to: '/billing' },
])

Dynamic items come from a runtime source file. A module that must work without Menu registers its source with addOptionalMenuSource from @frigear-nu/module-kit/utils. It does nothing when Menu is not installed.

Widget areas and tabs

Widget offers two registries with the same ordering and gating. A widget or tab may name a permission; widgets:filter and tabs:filter hide it from viewers who lack it.

  • Widgets fill a named widget area. The host app renders <WidgetArea name="admin.dashboard"> wherever it likes. Modules add components with extendWidgets.
  • Tabs fill a detail page. <WidgetTabs name="admin.users"> renders every registered tab, with the active tab in ?tab=.
import { extendWidgets, extendTabs } from '@frigear-nu/widget/utils'

extendWidgets(nuxt, 'admin.dashboard', { component: resolver.resolve('./runtime/app/widgets/NewMembers.vue'), order: 10 })
extendTabs(nuxt, 'admin.users', { id: 'payments', component: resolver.resolve('./runtime/app/tabs/Payments.vue'), label: 'Payments' })

An entry registered again under the same id replaces the earlier one. That is how a host app overrides a module's widget.

Use WidgetTabs when other modules should add sections to a page. Use PageTabs when the page owns them (see Pages).

Admin navigation and dashboard

Admin draws the admin area entirely from the two registries above. The sidebar is three menu groups:

GroupMenu pathPut here
Overviewadmin.nav.overviewThe dashboard
Manageadmin.navWhat staff manage every day
Settingsadmin.nav.settingsConfiguration

The dashboard is three widget areas:

Dashboard areaPut here
admin.dashboard.topOne StatCard per widget
admin.dashboardLists and tables
admin.dashboard.sideShort ListCards
import { extendAdminNav, extendAdminDashboard, adminRoute } from '@frigear-nu/admin/utils'

extendAdminNav(nuxt, { label: 'Forms', to: adminRoute(nuxt, 'forms'), icon: 'i-lucide-clipboard-list', order: 20 })
extendAdminDashboard(nuxt, { component: resolver.resolve('./runtime/app/widgets/Submissions.vue'), order: 10 })

New features go into one of the existing groups. A module can also call extendMenu and extendWidgets directly, without depending on Admin. See Layouts and navigation.

Account menu and overview

Account uses the same registries for the account pages:

  • The account menu is the account navigation and the overview's shortcuts.
  • account.dropdown is the account menu in the header.
  • A module adds an account page by registering the route and a menu item. It never draws its own account navigation.
  • The account.overview widget area fills the overview page. Its widgets receive the session user as a prop.
  • order places a page between others. Membership sits at 15, Tickets at 40 and Applications at 50.

MCP tools

MCP serves one Model Context Protocol endpoint for everybody. A module adds tools from its setup() with addMcpDefinitions. It defines them with defineScopedMcpTool, defineScopedMcpResource and defineScopedMcpPrompt:

addPermission(nuxt, { name: 'notes:read', label: 'Read your notes', implicit: true })
if (options.settings?.mcp === true) addMcpDefinitions(nuxt, resolver.resolve('./runtime/server/mcp'))
  • Every definition declares the permission it needs.
  • A tool is listed and callable only when allows(event, permission) is true for the caller's credential, so a token that holds only tickets:read never sees an admin tool.
  • A tool takes the user from ctx.user, never as an argument.
  • ctx.can(permission, record?) asks a further permission for the same caller, such as an opt-in *:pii.
  • Exposing tools is opt-in per module, through settings.mcp.

Auth extension points

@frigear-nu/auth/utils offers these build-time registries. Any module may call them from its setup():

HelperRegisters
addPermissionThe resource:action permissions your module checks, with implicit, sensitive, sessionOnly, aliases and grantTo.
addRoleA default role, such as eventmanager. The host's config merges into it.
addScopeA named scope that expands to permissions, for API tokens and OAuth.
addRoleRequirementA requirement a role can name, such as subscription.
addPoliciesA file of definePolicy and defineSharedPolicy exports.
addAuthResolverA custom bearer credential, by prefix or as a JWT.
setAuthUserStoreWhere users are stored. The first registration wins.

Users registers the user store. That is why Auth works without depending on it. Authorization registers the role provider in the same way. Access control shows each one in use, and how the host's config merges with what modules register.

Bootstrap providers

Some pages show several modules' data for the signed-in user, such as the account pages. Without help they make one request per module. registerBootstrapProvider adds a module's piece to the single GET /api/auth/bootstrap?scope=<scope> request of that area. See Module Kit.

Register only data that a page of the area shows on first render.

Common mistakes

Trusting a hidden widget. Gating a widget only hides it in the interface. Always enforce permissions on the server for the data a widget loads.
Reading the session in a hook listener. Listeners identify the caller from ctx.user, never from the session on ctx.event. The session is empty for bearer requests.

Next steps

Copyright © 2026