Writing a module

Setup checklist

Declare a module's dependencies and write its setup() with the module-kit helpers, in order.

This page is for module authors with a package from Scaffold a module. It covers the two parts of src/module.ts that every module has: the modules it depends on, and the setup() that registers its pages, routes, translations and tables.

Declare dependencies

List every module yours needs in moduleDependencies. Nuxt then installs and orders them, so the host app lists only your module.

export default defineNuxtModule<ModuleOptions>({
  meta: { name: '@frigear-nu/my-module', configKey: 'myModule' },
  moduleDependencies: {
    '@nuxthub/core': { version: '>=0.10' },
    '@frigear-nu/module-kit': { version: '>=0.0.1' },
    '@frigear-nu/auth': { version: '>=0.0.1' },
    '@frigear-nu/menu': { version: '>=0.0.1', optional: true },
  },
  // ...
})
KindIn moduleDependenciesIn package.json
Required{ version: '>=0.0.1' }dependencies with workspace:^ (not workspace:*, which publishes an exact version).
Optional{ version: '>=0.0.1', optional: true }An optional peerDependencies entry (with peerDependenciesMeta) and a devDependencies entry, so tests can use it.
Nuxt toolchain (nuxt, @nuxt/kit, @nuxt/ui, @nuxthub/core, drizzle-orm, vitest, ...)-catalog:, from the catalog of the root pnpm-workspace.yaml.
  • The catalog. Every package shares one copy of each toolchain package. A version of its own per package multiplies the installed Nuxt copies and slows every boot.
  • Module Kit always. Its entry provides the useModuleT auto-import. Add @nuxthub/core only when the module has tables.
  • Optional modules. An installed optional module is set up before yours, so hasNuxtModule('@frigear-nu/menu') in setup() does not depend on the order of the host app's modules. Guard every use of it. Load its helpers with a dynamic import() inside the guard, for example await import('@frigear-nu/widget/utils').
  • Options. moduleDependencies may be a function of nuxt when a dependency depends on options, as @frigear-nu/users does for MCP.

The setup() checklist

Most modules call the same helpers, in this order:

import { addServerImportsDir, createResolver, defineNuxtModule } from '@nuxt/kit'
import {
  addModulePages,
  addModuleRoutes,
  inlineModuleRuntime,
  registerDatabaseTable,
  registerLocales,
  registerModule,
  registerTailwindSource,
  resolvePagePath,
} from '@frigear-nu/module-kit/utils'

const MODULE_NAME = '@frigear-nu/my-module'

export default defineNuxtModule<ModuleOptions>({
  // meta, defaults, moduleDependencies ...
  setup(options, nuxt) {
    const resolver = createResolver(import.meta.url)

    // 0. Bundling and server auto-imports of ./runtime, also when the module is installed from npm.
    inlineModuleRuntime(nuxt, resolver)

    // 1. Make the module known to the rest of the app.
    registerModule(MODULE_NAME, nuxt)

    // 2. Classes used in the module's components must be generated by Tailwind.
    registerTailwindSource(nuxt, resolver.resolve('./runtime/app'))

    // 3. Translations: da.json and en.json, namespaced by module.
    registerLocales(nuxt, resolver.resolve('./runtime/i18n/lang'))

    // 4. Pages are opt-in or overridable through settings.pages.*.
    addModulePages(MODULE_NAME, resolver, [
      {
        name: 'my-module',
        path: resolvePagePath(options.settings?.pages?.index, '/my-module'),
        file: './runtime/app/pages/index.vue',
      },
    ])

    // 5. Server routes and server auto-imports.
    addServerImportsDir(resolver.resolve('./runtime/server/utils'))
    addModuleRoutes(resolver, [
      { method: 'get', path: 'things', file: 'things.get' },
      { method: 'get', path: '/api/admin/my-module/things', file: 'admin/things.get' },
    ], { base: '/api/my-module' })

    // 6. Tables: a plain drizzle file per dialect. No `name`, nothing to await.
    registerDatabaseTable(nuxt, {
      resolver,
      schema: dialect => `./runtime/server/db/schema.${dialect}`,
    })
  },
})

Why each call is there:

  • Names. registerModule takes the full package name. moduleUrl and moduleApiUrl take the short name ('my-module') and kebab-case it into the path.
  • inlineModuleRuntime. Call it in every module with a runtime folder. Nitro auto-imports only into the node_modules files of layers. Without it, an installed module's server code that uses auto-imports, such as useNitroApp, fails in the host app, and its virtual modules do not resolve.
  • App imports. App code in runtime (components, composables, plugins, pages) imports from #imports: import { computed, useModuleT } from '#imports'. Nuxt does not auto-import into a module in node_modules. The no-undef rule of ESLint config fails the lint when an import is missing. Names from optional modules come from your own addOptionalImports virtual module instead.
  • Access control. Register what your routes check with addPermission (plus addScope, addRole, addRoleRequirement, addPolicies as needed) and check it with authorize, not the session helpers. List @frigear-nu/auth in moduleDependencies. Access control covers it.
  • Routes. Register every route through one addModuleRoutes call. file is relative to ./runtime/server/routes/, and path is joined onto base unless it starts with /. Every module's API follows the same URL, error and response rules, in HTTP routes.
  • Optional areas. Menus, widgets and admin pages come from optional modules. Use addOptionalMenuSource and the guarded imports in Module Kit.

Next steps

Copyright © 2026