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.
Table of Contents
- 1. What a Nuxt module actually is
- 2. The difference between a module and a plugin
- 3. The defineNuxtModule() API in detail
- 4. Hooking into the build process
- 5. Typed module options and defaults
- 6. Registering your own DevTools tab
- 7. DevTools tab content: iframe or embedded Vue component
- 8. Runtime config and controlling behavior at runtime
- 9. Publishing and maintaining a module
- 10. Summary
- 11. FAQ
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.