"Walk me through what happens when I run nuxt build" is the canonical internals question, and it is a good one: the answer reveals whether you think of Nuxt as an API or as a pipeline. Every kit helper you call in a module is a write into one of the stages below, and every "my module did nothing" bug is a write that landed after the stage that reads it.
loadNuxtConfig uses c12 to read nuxt.config.ts (through jiti, so TypeScript works with no build step), resolve extends into the layer chain, load .env, and apply NUXT_* environment overrides and $development / $production / $env blocks. Layer configs are merged with defu, highest priority first.packages/schema, where every option has $default / $resolve logic. This is why nuxt.options.srcDir is an absolute path even though you wrote a relative one, and why reading a raw nuxt.config value is not the same as reading nuxt.options.nuxt.options is final for user-provided values — nothing you write in a module changes what the user asked for — but it stays mutable by modules for the whole of the module phase. That mutability is the API.nuxt.options.modules. The sequence is modules:before → each module's setup() awaited in turn → modules:done → ready.setup() you can only see the mutations of modules that ran before you. To read the fully merged picture — all modules' options, every registered component — do the work in modules:done or ready, not in setup().app:resolve fixes the resolved app: plugins, layouts, middleware, app.vue, the root component. Then app:templates lets modules add or edit templates, which are written into .nuxt and reported by app:templatesGenerated.build:before → Vite builds the client bundle and the server bundle from one shared config → Nitro: nitro:config (mutate the config), nitro:init (the Nitro instance exists, register its hooks), nitro:build:before, the server bundle rolled up into .output/server, nitro:build:public-assets → prerender:routes → build:done → close.dev:reload; builder:watch fires for watched files with the event and path, which is the hook you use to regenerate templates.The order is easier to remember as a diagram than as prose:
nuxt dev / nuxt build
└─ c12: nuxt.config + layers + .env + NUXT_* ──▶ merged config
└─ untyped schema ($default / $resolve) ──▶ nuxt.options
└─ hookable instance created
├─ modules:before
├─ internal modules (pages, components, imports, nitro, …)
├─ user modules (nuxt.options.modules order) → setup() each
├─ modules:done ← read the merged picture here
└─ ready
├─ app:resolve → app:templates → .nuxt/* → app:templatesGenerated
└─ build:before
├─ Vite: client bundle + server bundle
├─ nitro:config → nitro:init → nitro:build:before
├─ .output/server + nitro:build:public-assets
├─ prerender:routes
└─ build:done → close
A module that prints its own position in that sequence is the fastest way to internalise it:
import { defineNuxtModule } from '@nuxt/kit'
export default defineNuxtModule({
meta: { name: 'trace' },
setup(_options, nuxt) {
const t0 = Date.now()
const at = (name: string) => console.log(`[trace +${Date.now() - t0}ms] ${name}`)
at('setup')
nuxt.hook('modules:done', () => at(`modules:done (${nuxt.options.modules.length} modules)`))
nuxt.hook('ready', () => at('ready'))
nuxt.hook('app:resolve', app => at(`app:resolve (${app.plugins.length} plugins)`))
nuxt.hook('app:templates', () => at('app:templates'))
nuxt.hook('build:before', () => at('build:before'))
nuxt.hook('nitro:config', () => at('nitro:config'))
nuxt.hook('nitro:init', () => at('nitro:init'))
nuxt.hook('prerender:routes', () => at('prerender:routes'))
nuxt.hook('build:done', () => at('build:done'))
nuxt.hook('close', () => at('close'))
},
})
The practical rule that falls out of it — decide which stage needs your write, then pick the hook:
export default defineNuxtModule<ToolkitOptions>({
meta: { name: 'toolkit', configKey: 'toolkit' },
setup(options, nuxt) {
// stage 1: options mutation — later modules and every later stage will see it
nuxt.options.css.push(resolver.resolve('./runtime/toolkit.css'))
// stage 2: react to something that has not happened yet
nuxt.hook('modules:done', () => {
// every module has registered; now it is safe to inspect the final set
if (!nuxt.options.modules.some(m => m === '@nuxt/image')) {
logger.warn('toolkit: @nuxt/image is not installed; <ToolkitImage> falls back to <img>')
}
})
// stage 3: Nitro does not exist yet in setup() — configure it when it is built
nuxt.hook('nitro:config', (nitroConfig) => {
nitroConfig.storage ||= {}
nitroConfig.storage.toolkit = { driver: 'memory' }
})
// stage 4: dev only — regenerate when the consumer edits a watched file
nuxt.hook('builder:watch', async (event, path) => {
if (path.includes('/toolkit.routes.')) await updateTemplates({ filter: t => t.filename.startsWith('toolkit/') })
})
},
})
nuxt.options.nitro is only read when Nitro's config is assembled. A write inside nitro:init, build:done or any later hook is a no-op that fails silently — no warning, no error, just a storage mount or an external that is missing in .output. Anything that has to reach Nitro goes in setup() or in the nitro:config callback; anything that has to hook into Nitro's own lifecycle goes in nitro:init, where you get the Nitro instance and call nitro.hooks.hook(...).
A module that iterates nuxt.options.components or checks whether another module registered something will see only what ran before it, and module order depends on the consumer's config and their layer chain — so it works in your playground and breaks in their app. If you need the complete picture, do it in modules:done; if you need another module to exist, declare moduleDependencies rather than guessing at order.
Two fast-moving details: the shape of the debug option (Nuxt 4 accepts an object, so debug: { hooks: true } traces hook timings — confirm the exact key), and where the Nitro integration now lives, since it has been moving out of the nuxt package into a separate @nuxt/nitro-server package. Re-check both, and skim packages/schema/src/config/ for the $resolve of one option you rely on.
modules/trace.ts above into a playground and run nuxt build. Write the printed order down from memory afterwards and diff it against the output.nuxt.config.ts and compare the full hook list with your trace — count how many hooks fire before your setup() runs.nuxt.options.toolkit = { flag: true } in setup(), and read it from the first module in setup() and again in modules:done. Swap the order of the two modules and observe which read changes.nuxt.options.nitro.storage write from setup() into a nitro:init handler and confirm the mount disappears from .output without any warning."c12 loads nuxt.config, resolves the extends chain into the layer list, applies .env and NUXT_* overrides and merges everything with defu. That merged object is resolved through Nuxt's untyped schema, where every option has a $default or $resolve, which is what produces nuxt.options. Nuxt creates the hookable instance, then runs its internal modules and the user's modules in order — modules:before, each setup awaited in turn, modules:done, ready. Then app resolution: app:resolve fixes plugins, layouts and middleware, app:templates lets modules generate files, and the templates are written into .nuxt. Then the build: build:before, Vite's client and server bundles, then Nitro through nitro:config, nitro:init and nitro:build:before into .output/server, public assets, prerendering via prerender:routes, and finally build:done and close. The reason I care about the exact order is that nuxt.options is only mutable during the module phase — a write after the stage that reads it is a silent no-op."
Under the hood
The startup sequence, the server engine, the unjs toolbelt, auto-imports and build-time transforms — the machinery a layer and module author has to reason about rather than use.
Nitro and h3
The server engine underneath Nuxt — presets and .output, route rules, cached handlers, storage, per-request state, server plugins, and how a module extends all of it.