In every Nuxt project, sooner or later, code appears that begs to be reused: a platform-specific fix, a counter integration, a service wrapper. Copying it between projects is a bad strategy: the copies drift, edits get lost, and six months later nobody remembers which version is canonical. The right move is to package the code into a Nuxt module and publish it to npm. At PROWEB we did this with two open-source modules and will walk through both, from simple to advanced.
Why package code into a module at all
- A single source of truth. The fix is made in the module and reaches the projects as a version update — not as copy-paste
- Configuration instead of code edits. Module options live in the project's
nuxt.config.ts; the code itself stays untouched - Self-contained wiring. The module registers its plugins, auto-imports and styles itself — integration is one line in
modules - Versions and changelog. It's clear what changed between versions and what is safe to upgrade to
- Use beyond the studio. An npm package can serve other people's projects too — both of our modules are open:
nuxtjs-mobile-vhandnuxtjs-vk-pixel
Anatomy of a Nuxt module
A minimal module is an entry point module.ts with defineNuxtModule: here you set the name, the config key, the defaults, and register everything the module brings into the app. A short version from the mobile-vh module:
export default defineNuxtModule<ModuleOptions>({
meta: { name: 'mobile-vh', configKey: 'mobileVh' },
defaults: { id: DEFAULT_ID },
setup(_options, nuxt) {
const resolver = createResolver(import.meta.url)
addPlugin({ src: resolver.resolve('./runtime/plugin'), mode: 'client' })
},
})
Then come three building blocks almost any module is assembled from:
- Runtime plugins — code that runs inside the user's app: event subscriptions, lifecycle hooks, provide objects.
mode: 'client'means “browser only” — wherewindowdoesn't exist on the server - provide — the plugin returns an object available across the whole app as
$name - Auto-imports —
addImportsmakes a composable available in components without import lines
The simple example: nuxtjs-mobile-vh — the iOS 100vh fix
A classic pain of mobile layout: in iOS Safari the address bar appears and disappears on scroll, while 100vh does not shrink — blocks end up “taller” than the real screen and the bottom gets cut off. The canonical solution is a CSS variable --vh equal to one percent of the real window height. We packaged the solution as the nuxtjs-mobile-vh module (v1.1.0 on npm): install it, and any style can use calc(var(--vh, 1vh) * 100).
The core is a client plugin: it measures the height, sets the variable, updates it on resize with its own 100 ms debounce, and exposes a reactive ref via provide:
const vh = ref(0)
const setVh = () => {
vh.value = window.innerHeight * 0.01
document.documentElement.style.setProperty('--vh', `${vh.value}px`)
}
const debouncedResize = debounce(() => setVh(), 100)
The __vh class is attached to the app container in the app:mounted hook, and the module's default style switches the height to the variable with a careful fallback:
.__vh {
display: flex;
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100);
}
The reactive ref travels through provide('vh', vh): if a component's logic needs the real window height, it can read it reactively instead of parsing CSS. The module's only option is the container id — everything else works out of the box.
The advanced example: nuxtjs-vk-pixel and @nuxt/scripts
The second module integrates the VK pixel (the Top.Mail.Ru counter). The task sounds like “insert a script”, but done properly it needs the whole trimmings: loading without hurting performance, a noscript fallback, typed events instead of a raw global object. Instead of manual insertion, the module builds on @nuxt/scripts — the official Nuxt solution for third-party scripts:
export default defineNuxtModule<ModuleOptions>({
meta: { name: 'vk-pixel', configKey: 'vkPixel' },
moduleDependencies() {
return { '@nuxt/scripts': {} }
},
setup(options, nuxt) {
nuxt.options.runtimeConfig.public.vkPixel = { ...defaultOptions, ...options }
addPlugin(resolver.resolve('./runtime/plugin'))
addImports({
name: 'useVkPixel',
as: 'useVkPixel',
from: resolver.resolve('./runtime/composable'),
})
},
})
- moduleDependencies — the module guarantees
@nuxt/scriptsis installed; users don't need to remember a peer dependency - addImports — the
useVkPixelcomposable is available in components without imports - options via runtimeConfig.public — the counter id is set in the project's config, not in code
The plugin uses useScript with the onNuxtReady trigger: the pixel starts after hydration and doesn't delay the first paint, while the use() hook returns a typed proxy instead of the raw window._tmr:
const { proxy } = useScript<IVkPixelTmr>(scriptSrc, {
trigger: 'onNuxtReady',
use() {
;(window._tmr || (window._tmr = [])).push({
id,
type: 'pageView',
url: window.location.href,
start: (new Date()).getTime(),
})
return window._tmr as IVkPixelTmr
},
})
For users without JavaScript the module adds a noscript fallback via useHead with tagPosition: 'bodyOpen' — the counter image lands at the top of body, exactly as the canonical snippet requires:
useHead({
noscript: [{
tagPosition: 'bodyOpen',
textContent: () =>
`<div><img src="${noscriptSrc}?id=${id};js=na" style="position:absolute;left:-9999px;" alt="Top.Mail.Ru" /></div>`,
}],
})
The typed proxy injects the counter id and the current URL into every event: components call useVkPixel().push({ type: 'reachGoal', goal: 'purchase' }) without thinking about the protocol.
Testing: unit tests and fixtures
The module has two test levels. The first is unit tests of the plugin in vitest: window and document are replaced with mocks, and the debounce is checked with fake timers — “no more than one call within 100 ms” is pinned down literally. The second level is e2e on a fixture: test/fixtures/basic holds a minimal Nuxt app with the module enabled, and @nuxt/test-utils boots it like a real project:
await setup({
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
})
it('renders the index page', async () => {
const html = await $fetch('/')
expect(html).toContain('<div>basic</div>')
})
The fixture catches a class of errors unit tests can't see: a mis-registered plugin, broken auto-imports, conflicting options. If the fixture builds and renders — the module is safe to put into real projects.
Publishing: build and release in one command
- nuxt-module-builder builds the module and its types: the build hangs on
prepack(nuxt-module-build build), and development has a--stubmode - vitest + @nuxt/test-utils — run unit tests and the fixture before publishing
- changelogen — maintains the CHANGELOG and version from commit history
- lefthook + commitlint (in the second module) — keep vague commits out of history so changelogen can understand what changed
In both modules the release is a single script:
npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags
Lint, tests, build, a new version from commits, publish and tag push — one command. Cheap automation that removes the most common release mistake: “forgot to build dist before publish”.
In short
A Nuxt module is an entry point with defineNuxtModule, runtime plugins with provide, and auto-imports. Even a utility like a 100vh fix deserves a module: one line in modules instead of copy-paste between projects. For third-party script integrations use @nuxt/scripts: moduleDependencies, useScript with the onNuxtReady trigger, and a typed proxy instead of raw global objects. Test on two levels — plugin unit tests and an e2e fixture — and make the release a single script with changelogen: build, version, publish and tag in one command. Both modules from the article are open: nuxtjs-mobile-vh and nuxtjs-vk-pixel are searchable on npm.
