Hooks are how modules cooperate without importing each other. Mutating nuxt.options only affects modules that run after you; a hook lets you react to something that has not happened yet, and lets someone else react to something you do. A senior answer names the family a hook belongs to before naming the hook, because the three families live in three different processes and mixing them up is the most common module bug after the build/runtime boundary itself.
nuxt.hook(name, fn) registers, nuxt.callHook(name, ...args) runs. Listeners run serially in registration order and each one is awaited; callHookParallel runs them with Promise.all when order does not matter; hookOnce unregisters after the first call; hook() returns an unsubscribe function, which is what you keep when a module registers something conditionally or in a dev-only path.callHookWith lets you wrap the whole set of listeners (Nuxt uses it for timings and error capture). addHooks(obj) registers a map at once — that is exactly what the hooks field of defineNuxtModule does, before setup runs.nuxt — nuxt.hook('pages:extend', …). Node, during build. Only modules and nuxt.config's hooks key can use them.nuxtApp — nuxtApp.hook('page:finish', …) from a Nuxt plugin. Browser and SSR. Examples: app:created, vue:setup, app:rendered (server), app:beforeMount, app:mounted, app:suspense:resolve, page:start, page:finish, page:loading:start, page:loading:end, link:prefetch, app:error, vue:error, app:data:refresh, app:manifest:update, dev:ssr-logs.nitroApp.hooks — from a Nitro plugin registered with addNitroPlugin. Server only, per request: request, beforeResponse, afterResponse, render:html, render:response, error, close.| I want to… | Hook / helper |
|---|---|
| Change Nitro config (virtual modules, externals, storage, route rules) | nitro:config |
| Register Nitro runtime hooks or reach the Nitro instance | nitro:init → nitro.hooks.hook(…) |
| Add or modify pages | pages:extend (or extendPages) |
| Add a component directory late, or alter registration | components:dirs, components:extend |
| Add auto-imports programmatically | imports:extend, imports:sources |
| Regenerate a template when a watched file changes | builder:watch → updateTemplates({ filter }) |
| Extend Vite config | vite:extendConfig (deprecated for Nuxt 5 Nuxt 5 — prefer a plugin with applyToEnvironment) |
| React after every module has run | modules:done, then ready |
| Add type references manually | prepare:types |
| Extend prerendered routes | prerender:routes (or addPrerenderRoutes) |
| Clean up child processes and watchers | close |
| Extend the config schema | schema:extend |
NuxtHooks (build), RuntimeNuxtHooks in #app (app runtime) or NitroRuntimeHooks (Nitro), then calling them with nuxt.callHook / nuxtApp.callHook / nitroApp.hooks.callHook. This is how @nuxtjs/i18n exposes i18n:registerModule and how Tailwind exposes tailwindcss:config — and it is the right way to let a downstream team layer contribute to your toolkit without depending on your internals.hooks in nuxt.config is the consumer's version of the same mechanism. A team layer can therefore react to your custom hooks without writing a module at all.callHook may return void instead of always returning Promise<void>, so nuxt.callHook('x').then(…) breaks on undefined. The documented migration is to await the call, which is correct in both versions.Reacting, mutating and cleaning up in one module:
import { addTemplate, defineNuxtModule, updateTemplates, useLogger } from '@nuxt/kit'
import type { ToolkitRegistry } from './registry'
export default defineNuxtModule({
meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit' },
setup (_options, nuxt) {
const logger = useLogger('nuxt-team-toolkit')
const registry: ToolkitRegistry = { entries: [], add (entry) { this.entries.push(entry) } }
addTemplate({
filename: 'toolkit/registry.mjs',
getContents: () => `export const entries = ${JSON.stringify(registry.entries)}`,
})
// runs after every module: nuxt.options now reflects all of them
nuxt.hook('modules:done', async () => {
await nuxt.callHook('toolkit:extend', registry)
})
// regenerate only our own templates when the watched directory changes
nuxt.hook('builder:watch', async (_event, path) => {
if (!path.includes('toolkit-routes/')) { return }
await updateTemplates({ filter: t => t.filename.startsWith('toolkit/') })
})
// one-shot: report once, not on every rebuild in dev
nuxt.hookOnce('build:done', () => logger.info(`${registry.entries.length} toolkit entries`))
// hook() returns an unsubscribe function — keep it for anything conditional
const off = nuxt.hook('prepare:types', () => logger.debug('types written'))
nuxt.hook('close', () => off())
},
})
Declaring a hook other modules and layers can use:
import type { ToolkitRegistry } from './registry'
export interface ModuleHooks {
'toolkit:extend': (registry: ToolkitRegistry) => void | Promise<void>
}
declare module '@nuxt/schema' {
interface NuxtHooks extends ModuleHooks {}
}
// a consuming team layer contributes without writing a module
export default defineNuxtConfig({
extends: ['@team/nuxt-layer-base'],
hooks: {
'toolkit:extend' (registry) {
registry.add({ name: 'billing', path: '/billing' })
},
},
})
The two runtime families, for contrast:
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('render:html', (html, { event }) => {
html.head.push(`<meta name="x-request-id" content="${event.context.requestId}">`)
})
})
nitro:config receives the config object before Nitro exists — the only place to change externals, virtual modules, storage mounts or route rules. nitro:init receives the built instance, which is where you register Nitro runtime hooks from the build world. Trying to set nitroConfig.externals in nitro:init silently does nothing, and calling nitro.hooks.hook in nitro:config is a type error people work around by reaching for any.
updateTemplates() with no filter regenerates every template in the app, which in a large project turns a one-file save into a visible dev-server stall. Always pass { filter: t => t.filename.startsWith('toolkit/') }, and always check the changed path first so an unrelated save does no work at all.
Hooks fire once. Registering pages:extend from inside modules:done works, because pages are resolved later; registering modules:before from setup never fires, because it has already happened. When a hook "does nothing", the first question is whether your module ran before or after it. Anything that must see every module's contribution belongs in modules:done or ready.
modules:before, modules:done, ready, app:resolve, app:templates, pages:extend, imports:extend, components:dirs, nitro:config, nitro:init, build:before, vite:extendConfig, prepare:types, build:done, close). Run nuxt dev and then nuxt build, and write the observed order down as a sequence diagram.toolkit:extend as a build hook: call it from modules:done with a mutable registry, have a second inline module push an entry into it, and render the registry through a template.toolkit:ready as an app runtime hook from your plugin and listen for it in the playground's app.vue. Then expose a Nitro hook and listen for it from a Nitro plugin. Note which of the three you could not have done from a layer.callHook vs callHookParallel vs callHookWith in two sentences."Through hooks, not imports. I mutate nuxt.options for anything modules after me should see immediately, but anything that depends on all modules having run waits for modules:done or ready. If I need another module to be able to extend me, I declare my own hook by augmenting NuxtHooks and call it with nuxt.callHook — that is how i18n's i18n:registerModule works, and it means a consuming team layer can contribute through the hooks key in its nuxt.config without depending on my internals. The distinction I always make explicit is the family: build hooks on nuxt run once in Node, app hooks on nuxtApp run in the browser and during SSR, and Nitro hooks on nitroApp.hooks run per request on the server. Nitro configuration goes through nitro:config; Nitro behaviour goes through a Nitro plugin or nitro:init."
Rules of the runtime directory
What src/runtime may and may not import, how module-builder transpiles it file by file, the .client/.server suffixes, and how to make Nitro inline your server runtime so
Templates and types
Generating code into .nuxt with addTemplate, adding declarations with addTypeTemplate, regenerating only your own templates on watch, and getting the nuxt/schema augmentation right.