Module authoring

Dependencies and composition

installModule versus the declarative moduleDependencies field, detecting other modules with hasNuxtModule, choosing dependency versus peerDependency, and cooperating through hooks.

A toolkit module is rarely alone: it pulls in an icon set, a colour-mode module, maybe i18n, and it has to do that without stealing control from the consumer. Nuxt used to offer one tool for this — installModule — and it had no notion of ordering, version constraints or "the user already configured that". Nuxt 4.1 replaced it with a declarative field, and the comparison between the two is a favourite interview question because the answer is really about who owns the consumer's config.

Know

  • installModule(name, options?, nuxt?) runs another module's setup imperatively and immediately, inline in yours. It has no version constraints and no ordering logic. The kit reference now marks it deprecated in favour of moduleDependencies, and says it "will be removed (or may become non-blocking) in a future version" — so it is deprecated, not removed: it is still exported and still works on Nuxt 4. It still has one honest use: installing something conditionally at a point where you already know the decision and no constraint is needed.
  • moduleDependencies (Nuxt ≥ 4.1) is a field on the defineNuxtModule definition, alongside meta, defaults, schema, hooks, setup, onInstall and onUpgrade — that is the complete documented field list. Each entry takes exactly four keys, version, defaults, overrides and optional:
    moduleDependencies: {
      '@nuxt/icon': { version: '>=1', defaults: { size: '1em' }, overrides: { mode: 'svg' }, optional: false },
    }
    

    Nuxt resolves these before your setup runs: it checks the version constraint, orders the dependency ahead of you, and merges options so that defaults sit under the user's config and overrides sit over it. Nuxt UI uses the same mechanism to pull in @nuxt/icon, @nuxt/fonts and @nuxtjs/color-mode.
  • The merge direction is the whole point. defaults are your preference and the consumer can change them; overrides are a requirement of your integration and the consumer cannot. Putting something in overrides is a promise you will support forever, so use it only where a different value would actually break you.
  • optional: true means "configure it if it is there, do not install it if it is not". That is what you want for an integration the toolkit enhances rather than requires.
  • hasNuxtModule(name) answers "is this module present?" by checking _installedModules and the modules array, so it also sees modules declared after yours — unlike reading nuxt.options, which only reflects modules that already ran. hasNuxtModuleCompatibility(module, semver) adds a version check, with getNuxtModuleVersion underneath.
  • Dependency strategy in package.json:
    • @nuxt/kit — a normal dependency, pinned to the Nuxt major you support (^4.0.0). Your module imports it at build time; the consumer should not have to install it.
    • nuxt and vuedevDependencies plus peerDependencies. You build and test against them; the app owns the installed copy. Never a plain dependency, or a consumer ends up with two Vue instances.
    • Heavy runtime librariesdependencies, because src/runtime is transpiled rather than bundled and its imports stay live. Move one to a peerDependency (documented, with peerDependenciesMeta.optional where appropriate) only when the host app genuinely should control the version, for example a charting or i18n library the app also uses directly.
  • Cooperate through hooks, not imports. Consume other modules' hooks (i18n:registerModule, tailwindcss:config, components:extend) and expose your own (see Hooks). Importing another module's internals couples you to its file layout; a hook is a contract it maintains.
  • Layers change the ordering question. A team layer lists nuxt-team-toolkit in modules, and the consuming project may list it too. Nuxt deduplicates by meta.name, so it runs once — but the position comes from the merged array, which is why anything order-sensitive belongs in moduleDependencies or modules:done rather than in an assumption about where consumers put you.

How it works

Declarative dependencies, with a conditional integration alongside them:

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

export interface ModuleOptions { theme: 'default' | 'compact' }

const { resolve } = createResolver(import.meta.url)

export default defineNuxtModule<ModuleOptions>().with({
  meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit', version: '1.4.0', compatibility: { nuxt: '>=4.0.0' } },
  defaults: { theme: 'default' },
  moduleDependencies: {
    // required: version-checked, ordered before us, merged around the user's config
    '@nuxt/icon': {
      version: '>=1',
      defaults: { size: '1em' },      // user config wins over these
      overrides: { mode: 'svg' },     // we win over user config — a hard requirement
      optional: false,
    },
    // optional: configured if present, never installed on the user's behalf
    '@nuxtjs/color-mode': { version: '>=3', defaults: { classSuffix: '' }, optional: true },
  },
  async setup (_options, nuxt) {
    const logger = useLogger('nuxt-team-toolkit')

    // sees modules listed *after* us too, unlike reading nuxt.options
    if (hasNuxtModule('@nuxtjs/i18n')) {
      if (!await hasNuxtModuleCompatibility('@nuxtjs/i18n', '>=9')) {
        logger.warn('Toolkit translations need @nuxtjs/i18n >= 9; skipping registration.')
      }
      else {
        nuxt.hook('i18n:registerModule', register => register({
          langDir: resolve('./runtime/lang'),
          locales: [{ code: 'en', file: 'en.json' }],
        }))
      }
    }
  },
})

The imperative form, and why it reads worse:

src/module.ts
import { installModule } from '@nuxt/kit'

// runs @nuxt/icon's setup right here, right now:
//  - no version constraint, so an incompatible major fails deep inside someone else's code
//  - runs *before* the consumer's own modules regardless of what they intended
//  - your options are the only ones; there is no defaults/overrides distinction
await installModule('@nuxt/icon', { mode: 'svg' })

What the package.json says about all of it:

package.json
{
  "dependencies": {
    "@nuxt/kit": "^4.0.0",
    "defu": "^6.1.4"
  },
  "devDependencies": {
    "@nuxt/module-builder": "^1.0.0",
    "@nuxt/test-utils": "^3.19.0",
    "nuxt": "^4.5.0",
    "vue": "^3.5.0"
  },
  "peerDependencies": {
    "nuxt": "^4.0.0"
  }
}
Gotcha· overrides is a support commitment

Anything in overrides silently defeats a consumer's explicit config. The first bug report is "I set mode: 'css' and it is ignored", and the second is "your toolkit and our design system fight over the same module". Put the minimum in overrides, document each entry in the README, and prefer defaults plus a startup warning when the value you need is not the one you found.

Gotcha· hasNuxtModule in setup versus modules:done

hasNuxtModule checks the modules array as well as what has already run, so it is safe in setup for presence. Reading that module's effects (its runtime config, its aliases) is not: those only exist after it has run. Detect in setup, read in modules:done.

Gotcha· vue as a dependency

Listing vue under dependencies lets a package manager install a second copy under your module. Two Vue instances mean inject/provide misses, broken reactivity across the boundary and a duplicated runtime in the bundle — a bug that only appears in the consumer's app, never in your playground. vue and nuxt are dev plus peer, always.

Verify before the interview:

hasNuxtModule and hasNuxtModuleCompatibility are the one unverified pair here: neither is documented on the kit modules page or listed in the kit API index, so the behaviour described above comes from the kit source rather than the reference. Check the exported names against @nuxt/kit's own types before you lean on them in an answer.

docs ↗

Exercise

Exercise
  • Depend on @nuxtjs/color-mode through moduleDependencies with defaults: { classSuffix: '' }. Set a conflicting value in the playground's nuxt.config and confirm the user wins. Move the entry to overrides and confirm the opposite. Write down which of the two you would ship and why.
  • Add a version constraint you know fails (version: '>=99') and read the error Nuxt produces. Then make the dependency optional: true and confirm the build proceeds.
  • Create two playground fixtures, one with @nuxtjs/i18n and one without, and branch your setup on hasNuxtModule. Add a fixture where i18n is listed after your module in modules and confirm the detection still works.
  • Move vue from peerDependencies to dependencies, install the tarball into a clean app, and find the duplicate Vue in the bundle.

Be able to say

Be able to say· installModule or moduleDependencies — which do you use and why?

"moduleDependencies for anything real. It is declarative, so Nuxt resolves it before my setup runs: it enforces a semver constraint, orders the dependency ahead of me, and merges options with defaults under the user's config and overrides over it. optional: true covers integrations I enhance but do not require. installModule is imperative and immediate, has no version constraints and no ordering, and is now marked deprecated in the kit docs — it just runs someone else's setup inline in mine. The design point I make is about ownership: defaults are my preference and the consumer can change them, overrides are a promise I have to support forever, so I keep that list as short as possible. For anything I only want to detect, I use hasNuxtModule, which also sees modules declared after mine, and I cooperate through the other module's hooks rather than importing its internals."