Under the hood

The startup sequence

What actually happens between typing nuxt dev and getting a URL — c12 config loading, schema resolution, the hookable instance, module order, app resolution, the Vite and Nitro builds.

"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.

Know

  • Config load (c12). 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.
  • Schema resolution (untyped). The merged object is then resolved against Nuxt's schema in 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.
  • The instance. A hookable instance is created and the resolved options are attached to it. 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.
  • Modules, in order. Nuxt's own internal modules run first (pages, components, imports, the Nitro integration, head, and so on), then the user's modules in the order of nuxt.options.modules. The sequence is modules:before → each module's setup() awaited in turn → modules:doneready.
  • Order consequence. Inside 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 resolution. 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. 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-assetsprerender:routesbuild:doneclose.
  • Dev mode substitutes a Vite dev server and a Nitro dev server for the two builds. Changes to server files trigger dev:reload; builder:watch fires for watched files with the event and path, which is the hook you use to regenerate templates.

How it works

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:

modules/trace.ts
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:

modules/toolkit/src/module.ts
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/') })
    })
  },
})
Gotcha· Mutating nuxt.options.nitro after Nitro exists does nothing

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(...).

Gotcha· Reading nuxt.options in setup() gives you a half-built picture

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Drop 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.
  • Turn on hook debugging in nuxt.config.ts and compare the full hook list with your trace — count how many hooks fire before your setup() runs.
  • Add a second module that sets 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.
  • Move a nuxt.options.nitro.storage write from setup() into a nitro:init handler and confirm the mount disappears from .output without any warning.

Be able to say

Be able to say· Walk me through what happens when I run nuxt build.

"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."