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.
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.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.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.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.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.toolkit: false in nuxt.config and Nuxt skips the module's setup while still recording it as installed and disabled.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.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:
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:
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')
})
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.
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.
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.
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.
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.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.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."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."
Module authoring
What a Nuxt module really is, the three worlds every line of module code lives in, the three primitives everything reduces to, and why a Layers role is a module role in disguise.
The kit API, grouped
The @nuxt/kit helpers a module author should know from memory, grouped by what they do, with what each one does underneath and the source files worth reading.