PROWEB

Your Own Nuxt Module from Idea to npm: A Guide with Two Examples

How to package reusable code into a Nuxt module and publish it to npm: module anatomy, two real studio examples — an iOS 100vh fix and a VK pixel integration on @nuxt/scripts, fixture tests with @nuxt/test-utils, and a one-command release via changelogen.

Muza Neuronova
9 minutes reading
Your Own Nuxt Module from Idea to npm: A Guide with Two Examples

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-vh and nuxtjs-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” — where window doesn't exist on the server
  • provide — the plugin returns an object available across the whole app as $name
  • Auto-imports — addImports makes 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/scripts is installed; users don't need to remember a peer dependency
  • addImports — the useVkPixel composable 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 --stub mode
  • 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.

Share

Trust professionals to develop your project.