Module authoring

The mental model

What defineNuxtModule does with your definition, the three worlds a line of module code can live in, how Nuxt orders and deduplicates modules, and when onInstall and onUpgrade fire.

Interviewers open with "what is a module" because the answer shows whether you think in terms of build time versus runtime. The mechanics are short: one function, three worlds, a handful of defineNuxtModule fields, and two rules about ordering and deduplication that decide whether a company layer and a project can both list the same module.

Know

  • A module is a function. defineNuxtModule returns a normalised (inlineOptions, nuxt) => Promise<false | void | { timings }>. Nuxt calls it once, in Node, during nuxt dev, nuxt build or nuxt generate, sequentially with the other modules. It never runs in the browser or inside the production Nitro server.
  • Three worlds. Build world: src/module.ts, @nuxt/kit, Node APIs, the file system, the nuxt instance. Server runtime: src/runtime/server/** and the SSR side of plugins and composables, executed per request inside Nitro. Client runtime: src/runtime/app/**, executed in the browser once per tab session. Every line of code lives in exactly one of them.
  • Three primitives. Mutate nuxt.options, register hooks, generate or register files. Every kit helper is a wrapper over one of these.
  • meta: name (usually the npm name; also the dedupe key), configKey (where users put options in nuxt.config; defaults to name), version, compatibility ({ nuxt: '>=4.0.0' }, checked with checkNuxtCompatibility; an incompatible module is disabled with a warning, or the build throws when experimental.enforceModuleCompatibility is on) and docs.
  • defaults: an object or (nuxt) => object. Options are resolved as defu(inlineOptions, nuxt.options[configKey], defaults); an optional schema (untyped) then applies its own defaults. User config wins over your defaults, and arrays concatenate, which surprises everyone the first time.
  • defineNuxtModule<Options>().with({...}): the two-step form types defaults precisely, so setup receives options in which defaulted keys are no longer optional.
  • moduleDependencies (Nuxt ≥ 4.1): declarative dependencies with version, defaults, overrides and optional. Covered in Dependencies and composition.
  • hooks: a map of build hooks registered with nuxt.hooks.addHooks() before setup runs.
  • setup(options, nuxt): may be async. Return false to mark the module as ignored, or { timings } to report extra phases. Nuxt times the whole call itself and stores the result in nuxt.options._installedModules.
  • onInstall(nuxt) / onUpgrade(nuxt, options, previousVersion): one-time lifecycle hooks. Nuxt keeps a setups record in the project's .nuxtrc; when it installs a module whose meta.name has no entry it calls onInstall, when the recorded version is lower than meta.version it calls onUpgrade, then writes the new version. Both require meta.name and meta.version, otherwise they never fire.
  • Ordering is the order of nuxt.options.modules after layer configs are merged (project entries first, then each layer's). Mutations of nuxt.options are visible to later modules immediately. Anything that must see all modules waits for modules:done or ready.
  • Deduplication is by meta.name (falling back to configKey): the first install records nuxt.options._requiredModules[name], later installs return false. That is why a company layer and the project can both list nuxt-team-toolkit. Anonymous inline modules have no name and are never deduplicated.
  • Disabling: a consumer sets toolkit: false in nuxt.config and Nuxt skips the module's setup while still recording it as installed and disabled.
  • Bookkeeping you get for free: Nuxt pushes the module package's root onto build.transpile, adds its node_modules to modulesDir, fires module:before / module:done around every setup, and reads the module.json that @nuxt/module-builder emits next to module.mjs for build-time meta such as the version.

How it works

nuxt dev / nuxt build                      build world, Node, once
├─ c12 loads nuxt.config + layers  →  nuxt.options
├─ modules:before
├─ for each entry of nuxt.options.modules, in order:
│    resolve → import → dedupe by meta.name → compatibility check
│    → options = defu(inline, nuxt.config[configKey], defaults)
│    → addHooks(hooks) → module:before → setup(options, nuxt) → module:done
├─ modules:done  →  ready  →  app:resolve  →  app:templates  →  bundler build
└─ nitro:config  →  nitro:init  →  Nitro build  →  close

node .output/server/index.mjs              server runtime, per request
browser                                    client runtime, per tab session

A definition that uses every field:

src/module.ts
import { addPlugin, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'

export interface ModuleOptions {
  greeting: string
  features: string[]
}

const { resolve } = createResolver(import.meta.url)

export default defineNuxtModule<ModuleOptions>().with({
  meta: {
    name: 'nuxt-team-toolkit',
    configKey: 'toolkit',
    version: '1.4.0', // required for onInstall / onUpgrade to run at all
    compatibility: { nuxt: '>=4.0.0' },
    docs: 'https://toolkit.internal/docs',
  },
  defaults: { greeting: 'hello', features: [] },
  moduleDependencies: {
    '@nuxt/icon': { version: '>=1.0.0' },
  },
  hooks: {
    // registered before setup runs; identical to nuxt.hook('pages:extend', ...)
    'pages:extend' (pages) {
      pages.push({ name: 'toolkit-status', path: '/_toolkit', file: resolve('./runtime/app/pages/status.vue') })
    },
  },
  onInstall (nuxt) {
    // first time this project sees the module: no entry in .nuxtrc yet
    useLogger('nuxt-team-toolkit').info(`Installed into ${nuxt.options.rootDir}`)
  },
  onUpgrade (_nuxt, _options, previousVersion) {
    useLogger('nuxt-team-toolkit').info(`Upgraded from ${previousVersion}`)
  },
  async setup (options, nuxt) {
    // build world: options is fully resolved, nuxt.options is the merged config so far
    nuxt.options.alias['#toolkit'] = resolve('./runtime')
    addPlugin(resolve('./runtime/app/plugins/toolkit'))
    if (options.features.includes('status-page')) {
      // ...
    }
  },
})

The plugin it injects lives in a different world, which a single log line proves:

src/runtime/app/plugins/toolkit.ts
import { defineNuxtPlugin } from '#imports'

export default defineNuxtPlugin(() => {
  // terminal, once per request on the server; browser console, once per tab session
  console.log('[toolkit plugin]', import.meta.server ? 'server' : 'client')
})
Gotcha· Arrays in defaults concatenate

defaults: { features: ['status-page'] } plus a user's toolkit: { features: ['audit'] } yields ['audit', 'status-page'], because defu concatenates arrays. Keep array defaults empty and treat "empty means default" in setup, or normalise after resolution. When you merge objects yourself, createDefu from defu lets you give arrays replace semantics.

Gotcha· You only see the modules that ran before you

nuxt.options inside setup reflects the modules listed before yours. Reading nuxt.options.runtimeConfig.public.icon to detect Nuxt Icon works only if it is earlier in the array. Do detection in modules:done, use hasNuxtModule (which also checks the not-yet-installed entries of modules), or declare the relationship with moduleDependencies instead of relying on the consumer's ordering.

Gotcha· onInstall never fires

The lifecycle hooks are skipped silently unless both meta.name and meta.version are set on the definition; the module starter sets neither by default. They also run on every machine that has no .nuxtrc entry yet (CI, a fresh clone), so they must be idempotent and must never prompt.

Verify before the interview:

onInstall / onUpgrade, the .nuxtrc record and experimental.enforceModuleCompatibility are recent additions. Re-check the field list on the anatomy page and in packages/kit/src/module/define.ts the week before the interview.

docs ↗

Exercise

Exercise
  • Put console.log('[setup]') in setup, console.log('[plugin]', import.meta.server ? 'server' : 'client') in a runtime plugin and console.log('[handler]') in a server handler. Run pnpm dev, load a page, navigate client-side, hit the API. Write down exactly where each log appeared (terminal at startup, terminal per request, browser console) and why.
  • Add a second inline module in playground/nuxt.config.ts that reads an option your module set on nuxt.options. Swap the order in modules and observe when it becomes undefined. Fix it with modules:done.
  • Set meta.version, add onInstall and onUpgrade, run pnpm dev twice and read the setups entry in playground/.nuxtrc. Bump the version and watch onUpgrade fire once.

Be able to say

Be able to say· What is a Nuxt module and when does it run?

"A module is build-time code. It is a function Nuxt calls once, in Node, during nuxt dev or nuxt build, in the order of the modules array. It mutates nuxt.options, registers hooks and generates or injects files; everything the app executes at runtime comes from my runtime/ directory, injected with helpers like addPlugin. Options are resolved with defu, user config over my defaults, and Nuxt deduplicates modules by meta.name, which is what lets a company layer and a project both list the same module. If I need to see what every module did, I wait for modules:done rather than reading nuxt.options in setup."