Writing Custom Nuxt Modules: Build Hooks and a DevTools Tab of Your Own
AI generated
{ }
Nuxt 3 · Module Development
Writing Custom Nuxt Modules: Build Hooks and a DevTools Tab of Your Own
How modules really work and how to make them useful for your team

Nuxt modules are the tool for extending the build process, the runtime, and even the developer tooling of a Nuxt project. In this article we build a module step by step that hooks into the build process, ships typed options, and finally registers its own tab in Nuxt DevTools.

16 min read Nuxt 3 TypeScript

1. What a Nuxt module actually is

A Nuxt module is code that runs once in Node.js when the build starts, or when the dev server boots, and that can modify the project configuration itself while doing so. A module can auto-register components, add server routes, adjust the Vite or Webpack configuration, expose its own composables, or generate templates that flow into the project at build time. It has full access to the Nuxt object and therefore to every option that could also be set in nuxt.config, only programmatically instead of statically.

The entire Nuxt ecosystem is built on exactly this mechanism. Modules such as nuxt/image, nuxt/content, or nuxt/devtools are themselves nothing more than ordinary modules using the same public API that is also available for project-internal modules. Once you understand how a small module of your own is put together, you also understand how the larger community modules work under the hood, and you can read their source as a reference for your own solutions.

2. The difference between a module and a plugin

A plugin is defined via defineNuxtPlugin and runs on every application start, depending on configuration either on the client, on the server, or in both contexts. It has access to the Vue app instance as well as the Nuxt context and is typically used to provide global composables, initialize third-party libraries, or register directives. A plugin is therefore runtime code that becomes part of the shipped bundle.

A module, by contrast, runs only once in Node during the build and is itself never part of the code delivered to the browser. It configures the project and can very well register a plugin of its own, which then becomes active at runtime. Modules are meta-level tooling at the project level, plugins are runtime building blocks inside the finished application, and the two concepts frequently work together within a single feature.

3. The defineNuxtModule() API in detail

defineNuxtModule comes from the @nuxt/kit package and takes an object with three central fields: meta with name, configKey, and a compatibility range, defaults with the default values for the module options, and the setup function, which receives the Nuxt object as its second parameter. Inside setup, Nuxt Kit helper functions such as addPlugin, addComponent, addImportsDir, or extendPages are available, wrapping common tasks so you rarely need to poke at the Nuxt configuration manually.

On the consumer side, it is enough to list the module in the modules array of nuxt.config and optionally provide an options object under the key given in meta.configKey. The snippet below shows a minimal but complete module that registers a runtime plugin, exposes a composable via addImports, and logs a message when the build starts, unless the enabled option has been set to false.


import { defineNuxtModule, addPlugin, addImports, createResolver } from '@nuxt/kit'

export interface ModuleOptions {
  enabled: boolean
  panelTitle: string
}

export default defineNuxtModule<ModuleOptions>({
  meta: {
    name: 'my-debug-module',
    configKey: 'myDebugModule',
    compatibility: { nuxt: '^3.0.0' }
  },
  defaults: {
    enabled: true,
    panelTitle: 'Project Debug'
  },
  setup(options, nuxt) {
    const { resolve } = createResolver(import.meta.url)

    if (!options.enabled) {
      return
    }

    addPlugin(resolve('./runtime/plugin'))

    addImports({
      name: 'useProjectDebug',
      from: resolve('./runtime/composables/useProjectDebug')
    })

    nuxt.hook('build:before', () => {
      console.log(`[${options.panelTitle}] Build starting with debug module`)
    })
  }
})

4. Hooking into the build process

Nuxt is internally built on the hookable library and exposes an extensive, typed hook system through it. Calling nuxt.hook('name', callback) lets you tap into many points of the lifecycle, for example build:before and build:done around the actual build, pages:extend to alter the automatically discovered pages, vite:extendConfig or webpack:config to adjust the respective bundler configuration, and close when the process is shutting down. Every callback may be asynchronous, and Nuxt automatically waits for the returned promise before moving on to the next step.

In practice, hooks are useful for writing a generated file before the actual build starts, appending routes computed at runtime inside pages:extend, or defining an extra alias for an internal path inside vite:extendConfig. Because the complete list of available hooks is part of the @nuxt/schema package, it is worth checking the changelog on every new Nuxt minor release, since new hooks occasionally get added or existing ones get refined.

5. Typed module options and defaults

The generic type argument of defineNuxtModule makes sure the options inside the setup function are fully typed, and it gives users of the module autocomplete for every available field right inside nuxt.config. The values stored in defaults are automatically merged with whatever the user supplied, so a complete options object is already available inside setup without having to check manually whether a given field was set at all.

For options that go beyond pure build-time configuration and are also needed at runtime, simply putting them in defaults is not enough on its own; addTemplate and runtimeConfig, covered in the next section, take care of that. The distinction that matters here is simple: values that only steer the build itself stay in the module options, values that are also needed by running server or client code must be explicitly forwarded into the runtime config.

6. Registering your own DevTools tab

Nuxt DevTools expose a dedicated hook called devtools:customTabs, through which a module registers an additional tab object with a title, a unique id, an icon, and a view definition. The view can either be of type iframe, pointing at an arbitrary URL, or of type launch, to start an external process. The DevTools Kit then takes over the entire rendering inside the existing DevTools interface, so the custom tab slots in seamlessly next to the built-in tabs for pages, components, or modules.

A realistic example would be a tab that shows every active feature flag of the project during development, including the ability to toggle them for testing without touching the code itself. To do that, the module registers a development-only server route in addition to the tab definition, which serves the feature flags as a small HTML interface that gets embedded inside the DevTools tab via iframe.

7. DevTools tab content: iframe or embedded Vue component

There are fundamentally two ways to fill a DevTools tab with content. The iframe approach shows a route served by the module itself through addServerHandler inside an isolated frame, is technically the simplest to implement, and is completely independent from the rest of the DevTools interface. The alternative is a deeply integrated Vue component that talks directly to the running Nuxt client over the DevTools RPC, which lets it show live data straight from browser state without the detour of a dedicated HTTP route.

For getting started, the iframe approach is the better choice, since it requires far less code and no knowledge of the internal DevTools RPC mechanics. Many community modules begin exactly this way and only switch to the component-based approach once tighter integration with client state is genuinely needed. For an internal project tool, the iframe solution is almost always entirely sufficient.

8. Runtime config and controlling behavior at runtime

For values from the module configuration to also be available in running server or client code, the setup function explicitly writes them into nuxt.options.runtimeConfig, or, if the browser needs access as well, into nuxt.options.runtimeConfig.public. Both objects are then reachable through the useRuntimeConfig() composable, with anything under public actually landing in the shipped client bundle and therefore visible to anyone, while the rest stays strictly server-side.

For a pure developer tool like the debug module described here, it also matters to tie the registration of the DevTools tab and the debug routes to the nuxt.options.dev flag. That way the entire functionality stays reliably confined to local development, and no extra code or extra route ends up in the production build, even if the module is accidentally left in the production configuration.

9. Publishing and maintaining a module

For a module meant to be shared across several projects or teams, it is worth setting up a proper package structure with module.ts as the entry point, built with unbuild, and with @nuxt/kit listed as a peer dependency rather than a regular one, so no duplicate Nuxt Kit version ends up in the consuming project. For testing, @nuxt/test-utils provides an environment where a module can be started and checked against a minimal fixture project, without maintaining a full example project by hand.

When it comes to versioning, it pays off to keep the compatibility range in meta realistic and to check on every larger Nuxt release whether the hooks in use still behave the same way, since details can shift slightly between minor versions. If the module is published publicly on npm, an entry in the official Nuxt module directory is worthwhile too, which noticeably improves discoverability and brings in community feedback about compatibility issues.

Aspect Module Plugin Layer
When it runs Build / dev server start (Node) App boot, client and/or server Merged as base config when the app starts
Access Nuxt Kit utilities, full Nuxt config Vue app instance, Nuxt context, composables Entire project structure (pages, components)
Typical purpose Extend the build, integrate tooling Provide global composables/directives Reusable project base (theme, preset)
Registration modules array in nuxt.config plugins array or auto-scan extends array in nuxt.config
Example @nuxt/devtools, @nuxt/image VueQueryPlugin, Sentry init Company-wide design system as a base layer

Mironsoft

Vue architecture, Composition API, and Nuxt performance

Vue applications that don't get more complicated with every feature?

We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.

Architecture Review

Checking composables, state management, and component structure for maintainability.

Performance Audit

Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.

Nuxt Integration

Building robust, type-safe SSR/SSG setup and API integration.

10. Summary

Custom Nuxt Modules and DevTools Tabs at a Glance

API

defineNuxtModule() from @nuxt/kit with setup(options, nuxt).

Execution

Runs once at build or dev server start, inside Node.js.

DevTools

Registered through the devtools:customTabs hook.

Recommendation

Start with iframe-based tabs, move to Vue components later.

11. FAQ: Custom Nuxt Modules and DevTools Tabs at a Glance

1What is the difference between defineNuxtModule and defineNuxtPlugin?
defineNuxtModule defines code that runs once in Node when the build or dev server starts and modifies the project configuration, while defineNuxtPlugin defines code that runs on every app start on the client and/or server and typically registers composables or Vue plugins.
2Can a module register plugins itself?
Yes, through the addPlugin function from @nuxt/kit a module can hook in a runtime plugin during its setup, which then runs on every app start, this is a very common pattern in module development.
3Where does the setup code of a Nuxt module run?
The setup code runs in Node.js during the build, or when the dev server starts, so it has no access to browser APIs and is itself never included in the client bundle.
4How do I get my own tab in Nuxt DevTools?
Through the devtools:customTabs hook you register a tab object with a title, an icon, and a view, either as an iframe source or as an embedded Vue component, and the DevTools Kit takes care of rendering it.
5Do I need to build a dedicated server route for the DevTools integration?
For the iframe approach, yes, you register a route via addServerHandler that serves the debug interface as HTML; for the Vue component approach, you embed a component directly instead.
6Which build hooks are available to me?
Nuxt offers, among others, build:before, build:done, pages:extend, vite:extendConfig, webpack:config, and close, and the complete typed list lives in Nuxt's schema package and can shift slightly between minor versions.
7How do I prevent my debug module from being active in production?
You check the nuxt.options.dev flag inside the setup function and only register the DevTools tab and debug routes when that flag is true, so the functionality stays reliably confined to local development.
8How do I pass options to my own module?
You define an interface for ModuleOptions, set sensible defaults in the module, and the user overrides individual values in the object that sits under the key given in meta.configKey inside nuxt.config.
9Can I also use values from the module configuration at runtime in the client?
Yes, for that you write the desired values into nuxt.options.runtimeConfig.public, from where they are available in both server and client code through useRuntimeConfig, though then visible to anyone in the shipped bundle.
10Do I have to publish my module as a separate npm package?
No, for project-internal purposes a local module inside the project's modules folder is entirely sufficient, publishing only becomes worthwhile once several projects or teams want to share the module.