Building Custom Nuxt 3 Modules: From Concept to Published Package
Learn how to build custom Nuxt 3 modules that extend framework functionality — hooks, runtime plugins, auto-imports, and publishing to npm.
Strategic Systems Architect & Enterprise Software Developer
Nuxt modules are the primary extension mechanism for the framework, and they are more accessible to build than most developers realize. If you have ever copied the same composable, plugin, or server middleware across multiple Nuxt projects, you have a module waiting to be extracted. The module system gives you hooks into the build process, auto-imports, runtime configuration, and server route injection — all through a clean, well-documented API.
I have built several internal modules for projects where repeating setup across applications became a maintenance problem. Here is what I learned about doing it well.
The Module Anatomy
A Nuxt module is a function that runs at build time. It receives the Nuxt instance and can modify configuration, add plugins, register composables, inject components, and hook into the build lifecycle. The simplest module looks like this:
import { defineNuxtModule } from '@nuxt/kit'
Export default defineNuxtModule({
meta: {
name: 'my-module',
configKey: 'myModule',
},
defaults: {
enabled: true,
},
setup(options, nuxt) {
if (!options.enabled) return
// Module logic here
},
})
The defineNuxtModule wrapper from @nuxt/kit handles boilerplate — deduplication, option merging, compatibility checks. Always use it. Writing a raw module function works but misses safety features you will want later.
The @nuxt/kit package is your primary tool. It provides utilities for everything a module needs: addPlugin, addImports, addComponent, addServerHandler, createResolver, addTemplate. These functions are stable across Nuxt versions and handle edge cases you would otherwise discover in production.
Understanding how Nuxt modules fit into the broader Nuxt architecture helps you decide where your module should hook in and what lifecycle events matter for your use case.
Auto-Imports and Composables
One of the most common reasons to build a module is providing composables that auto-import across the consuming application. The pattern is straightforward:
import { defineNuxtModule, addImports, createResolver } from '@nuxt/kit'
Export default defineNuxtModule({
meta: { name: 'analytics-module', configKey: 'analytics' },
setup(options, nuxt) {
const { resolve } = createResolver(import.meta.url)
addImports([
{ name: 'useAnalytics', from: resolve('./runtime/composables/useAnalytics') },
{ name: 'usePageView', from: resolve('./runtime/composables/usePageView') },
])
},
})
The consuming application can then use useAnalytics() anywhere without importing it. This matches the developer experience of built-in Nuxt composables like useFetch and useRoute.
The runtime directory is important. Code in the module root runs at build time only. Code in runtime/ runs in the browser and server at request time. Composables, plugins, and components belong in runtime/. Build-time logic — adding routes, modifying webpack config, generating files — belongs in the module setup function.
Type safety matters here. Export your composable types from the module's entry point so consuming projects get full IntelliSense. The TypeScript patterns for Nuxt apply directly to module development, especially around generic composable signatures.
Hooks and the Build Lifecycle
Nuxt exposes dozens of hooks that modules can tap into. The most useful ones for module development are:
setup(options, nuxt) {
// Modify Nuxt config before build starts
nuxt.hook('modules:done', () => {
// All modules have loaded — safe to check for peer modules
})
nuxt.hook('components:dirs', (dirs) => {
// Register additional component directories
dirs.push({ path: resolve('./runtime/components') })
})
nuxt.hook('nitro:config', (nitroConfig) => {
// Modify server configuration
nitroConfig.alias = nitroConfig.alias || {}
nitroConfig.alias['#analytics'] = resolve('./runtime/server/utils')
})
nuxt.hook('pages:extend', (pages) => {
// Add or modify routes
pages.push({
name: 'analytics-dashboard',
path: '/admin/analytics',
file: resolve('./runtime/pages/dashboard.vue'),
})
})
}
The hook system is what makes modules genuinely powerful rather than just a packaging convention. You can modify almost any aspect of the application at build time — routes, middleware, server handlers, rendering configuration, head defaults.
A pattern I use frequently is conditional feature activation based on what other modules are installed. If your module integrates with authentication, check whether an auth module is present in the modules:done hook rather than requiring it as a hard dependency. This makes modules composable rather than monolithic.
Testing and Publishing
Testing a Nuxt module requires a test fixture — a minimal Nuxt application that uses the module. The @nuxt/test-utils package provides utilities for this:
import { describe, it, expect } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils'
Describe('analytics module', async () => {
await setup({
rootDir: './test/fixture',
})
it('injects analytics script', async () => {
const html = await $fetch('/')
expect(html).toContain('analytics.js')
})
})
The test fixture is a real Nuxt app in your module's test/fixture/ directory with a nuxt.config.ts that registers your module. This integration-level testing catches issues that unit tests miss — timing problems, hook ordering, conflicts with other modules.
For publishing, the standard approach is to use unbuild for the build step. It handles dual CJS/ESM output and preserves the directory structure modules need. Your package.json should export the module entry point and the runtime directory separately so that tree-shaking works correctly in consuming applications.
Before publishing, test your module in a real project using npm link or a file dependency. The build-time versus runtime boundary creates subtle issues that only surface when the module is consumed as a package rather than developed locally. I have found issues with path resolution, missing runtime files, and type declaration problems that were invisible during development.
Building modules is one of the most effective ways to reduce duplication across projects and enforce patterns consistently. The initial investment pays back quickly if you maintain more than one Nuxt application — and the skills transfer directly to understanding how the modules you depend on actually work, which is valuable when you need to debug or extend them. For related patterns around middleware, the same module hooks let you inject route guards programmatically.