Module authoring

Building, versioning and publishing

What nuxt-module-build emits, the package.json fields that decide whether your module resolves, semver for modules, deprecating an option safely, and getting listed on nuxt.com/modules.

Everything up to here happened in your repository, where the playground hides path mistakes, node_modules hides dependency mistakes and TypeScript source hides resolution mistakes. Publishing is where those surface, in someone else's app, as a broken build. For an internal toolkit there is a second theme: the release contract. Consumers of a company layer cannot choose not to upgrade, so what counts as a breaking change and how you deprecate is a real design question, not paperwork.

Know

  • nuxt-module-build build (usually wired as prepack) emits:
    • dist/module.mjs — your src/module.ts, bundled: kit and any build-time dependency inlined.
    • dist/module.jsonname and version, which Nuxt reads for module meta and onUpgrade.
    • dist/types.d.mts — the nuxt/schema augmentation generated from the interfaces you export.
    • dist/runtime/** — your runtime, transpiled file by file by mkdist at the same relative paths, so resolve('./runtime/…') still resolves after publishing. .ts/.js files come out as .js with a sibling .d.ts, .vue files keep their extension and gain a .d.ts, and anything else is copied unchanged.
  • npm pack tells you the truth. It prints exactly what ships. Anything you expected and cannot see is a files mistake; anything there you did not expect is bloat you are pushing into every consumer's install.
  • package.json essentials:
    • "type": "module" — modules are ESM.
    • "exports" with "types" first, then "import", pointing at dist/types.d.mts and dist/module.mjs.
    • "main" as a fallback for tools that ignore exports, and "typesVersions" — not a bare "types" — mapping "." to ./dist/types.d.mts for older TypeScript resolution modes. That is what the module starter ships.
    • "files": ["dist"] — nothing else needs to ship.
    • "sideEffects": false only if that is true; a module that ships CSS or registers globals is not side-effect free and marking it so gets code tree-shaken away.
    • "keywords" including nuxt and nuxt-module, which is what the listing scans.
  • Releases: conventional commits plus changelogen (changelogen --release) to bump the version, write CHANGELOG.md, commit and tag. The module starter's release script chains lint → test → prepack → changelogen → npm publishgit push --follow-tags, so a release that fails lint or tests never reaches the registry.
  • Semver, for a module specifically. A breaking change is anything that forces a consumer to edit code or config:
    • an option removed or renamed,
    • a default changed (silently different behaviour is worse than a build error),
    • the minimum Nuxt version raised,
    • a runtime API removed or renamed — an exported composable, a component name or prefix, an injected $toolkit key, a server route path. A new option with a backwards-compatible default is a minor. A generated-template change nobody imports directly is a patch.
  • Deprecate for one major. Keep the old option working, map it to the new one, and warn once at build time with useLogger. Remove only in the next major, after the migration is documented.
  • Publish a compatibility table in the README (toolkit 1.x → Nuxt 4.x, 2.x → Nuxt 5.x) and set meta.compatibility so an incompatible install is a warning at build time rather than a mystery at runtime. Pair it with the peerDependencies range.
  • Listing on nuxt.com/modules goes through a PR to the nuxt/modules repository, which adds a YAML entry (npm name, repo, category, maintainers). An internal toolkit skips this and lives on a private registry or a Git dependency — but the same metadata discipline is what makes it discoverable inside the company.
  • For a layers-first toolkit, version the layer and the module together. The layer's package.json depends on the module with a caret range it actually supports, one changelog covers both, and the compatibility table lists Nuxt version, layer version and module version in one row. Consumers upgrade one extends entry; you own the coherence behind it.

How it works

package.json
{
  "name": "nuxt-team-toolkit",
  "version": "1.4.0",
  "type": "module",
  "keywords": ["nuxt", "nuxt-module", "internal-toolkit"],
  "exports": {
    ".": {
      "types": "./dist/types.d.mts",
      "import": "./dist/module.mjs"
    }
  },
  "main": "./dist/module.mjs",
  "typesVersions": {
    "*": {
      ".": ["./dist/types.d.mts"]
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "prepack": "nuxt-module-build build",
    "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
    "test": "vitest run",
    "release": "pnpm lint && pnpm test && pnpm prepack && changelogen --release && npm publish && git push --follow-tags"
  }
}

Renaming an option without breaking anyone:

src/module.ts
import { defineNuxtModule, useLogger } from '@nuxt/kit'

export interface ModuleOptions {
  /** @deprecated use `apiBase` — removed in 2.0 */
  endpoint?: string
  apiBase: string
}

export default defineNuxtModule<ModuleOptions>({
  meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit', version: '1.4.0', compatibility: { nuxt: '>=4.0.0' } },
  defaults: { apiBase: 'https://api.internal' },
  setup (options, nuxt) {
    const logger = useLogger('nuxt-team-toolkit')

    if (options.endpoint) {
      // name the old option, the new option, and the version it disappears in
      logger.warn('`toolkit.endpoint` is deprecated and will be removed in 2.0. Rename it to `toolkit.apiBase`.')
      options.apiBase = options.endpoint
    }

    nuxt.options.runtimeConfig.public.toolkit = { apiBase: options.apiBase }
  },
})

Checking what actually ships, before anyone else does:

pnpm prepack && npm pack --dry-run   # the file list, straight from npm
pnpm prepack && npm pack             # then install the tarball into a clean app:
npx nuxi@latest init /tmp/clean-app && cd /tmp/clean-app
pnpm add /path/to/nuxt-team-toolkit-1.4.0.tgz && pnpm dev
Gotcha· It resolves in the monorepo and nowhere else

Workspace linking resolves src/ happily, so a missing exports entry, a wrong files list or a subpath import into dist/runtime/** that you never declared all work locally and fail for the first consumer. npm pack plus a scratch nuxi init app is a five-minute check that catches every one of them; do it before each release, not after the first bug report.

Gotcha· sideEffects: false on a module that ships CSS

If your runtime imports a stylesheet or registers something globally for its effect, "sideEffects": false licenses the bundler to drop that import entirely. The symptom is unstyled components in production builds only. Either list the real side-effectful files ("sideEffects": ["**/*.css"]) or leave the field out.

Gotcha· Changing a default is a breaking change

Renaming an option produces a warning a consumer can act on. Flipping theme from 'default' to 'compact' in a minor produces a design review three weeks later in an app nobody told. If the new value is genuinely better, ship it behind the new option in a minor, warn that the default changes in the next major, and change it there.

Exercise

Exercise
  • Run pnpm prepack and read every file in dist/. Open dist/types.d.mts and find the nuxt/schema augmentation generated from your exported interfaces.
  • npm pack, unpack the tarball, install it into a fresh npx nuxi init app and confirm the module works from node_modules. Deliberately move a runtime import to a devDependency first and watch it fail there while the playground stays green.
  • Rename an option, keep the old one working with a useLogger warning that names the replacement and the removal version, and write the CHANGELOG entry as you would for consumers.
  • Add a compatibility table to the README covering Nuxt version, layer version and module version, and set meta.compatibility to match.

Be able to say

Be able to say· What counts as a breaking change for a module, and how do you ship one?

"Anything that forces consumers to edit code or config: an option removed or renamed, a default changed, the minimum Nuxt version raised, or a runtime API removed — an exported composable, a component prefix, an injected key, a server route. A changed default is the nastiest of those, because it produces no error, so I treat it as breaking even though the types still compile. The process is: deprecate for one major, keep the old path working and map it to the new one, warn once at build time with useLogger naming the old option, the new option and the version it disappears in, and only remove after the migration is documented. I keep a compatibility table in the README and set meta.compatibility so an unsupported Nuxt version is a build-time warning rather than a runtime mystery, and because the toolkit is a layer plus a module I version them together so consumers upgrade one extends entry. Before every release I npm pack and install the tarball into a clean app, because that is the only thing that catches a devDependency used from src/runtime or a broken exports map."