Module authoring

Hooks

How unjs/hookable powers the three hook families, a goal-to-hook cheat sheet for module authors, and how to declare your own hooks so other modules can cooperate with yours.

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.

Know

  • Powered by unjs/hookable. 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.
  • Three families, three processes:
    1. Build hooks on nuxtnuxt.hook('pages:extend', …). Node, during build. Only modules and nuxt.config's hooks key can use them.
    2. App runtime hooks on nuxtAppnuxtApp.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.
    3. Nitro runtime hooks on nitroApp.hooks — from a Nitro plugin registered with addNitroPlugin. Server only, per request: request, beforeResponse, afterResponse, render:html, render:response, error, close.
  • Goal → build hook cheat sheet:
I want to…Hook / helper
Change Nitro config (virtual modules, externals, storage, route rules)nitro:config
Register Nitro runtime hooks or reach the Nitro instancenitro:initnitro.hooks.hook(…)
Add or modify pagespages:extend (or extendPages)
Add a component directory late, or alter registrationcomponents:dirs, components:extend
Add auto-imports programmaticallyimports:extend, imports:sources
Regenerate a template when a watched file changesbuilder:watchupdateTemplates({ filter })
Extend Vite configvite:extendConfig (deprecated for Nuxt 5 Nuxt 5 — prefer a plugin with applyToEnvironment)
React after every module has runmodules:done, then ready
Add type references manuallyprepare:types
Extend prerendered routesprerender:routes (or addPrerenderRoutes)
Clean up child processes and watchersclose
Extend the config schemaschema:extend
  • Declare your own hooks by augmenting 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.
  • Nuxt 5 changes the return type. Nuxt 5 The upgrade guide lists this as Non-Async callHook: with hookable v6, 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.

How it works

Reacting, mutating and cleaning up in one module:

src/module.ts
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:

src/types.ts
import type { ToolkitRegistry } from './registry'

export interface ModuleHooks {
  'toolkit:extend': (registry: ToolkitRegistry) => void | Promise<void>
}

declare module '@nuxt/schema' {
  interface NuxtHooks extends ModuleHooks {}
}
nuxt.config.ts
// 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:

src/runtime/server/plugins/request-id.ts
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}">`)
  })
})
Gotcha· nitro:config versus nitro:init

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.

Gotcha· Your template rebuilds everything

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.

Gotcha· Registering a hook too late

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.

Exercise

Exercise
  • Register a logger on roughly fifteen build hooks (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.
  • Expose 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.
  • Expose 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.
  • Read the hookable README and explain callHook vs callHookParallel vs callHookWith in two sentences.

Be able to say

Be able to say· How do modules cooperate with each other?

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