You are publishing a Nuxt 4 analytics module. How do you structure it with `defineNuxtModule`, and how do its options reach the plugin, composable and component it adds?
answer
- meta, defaults, setup
- resolve paths from the module file
- runtime code lives in its own folder
- options exist only at build time
- no auto-imports inside node_modules
basics
~20 sExport defineNuxtModule with meta (name, configKey, compatibility), defaults and a setup that registers runtime files with addPlugin, addImports, addComponent and addServerHandler. Options exist only at build time, so setup copies what runtime code needs into runtimeConfig.
solid answer
~40 sA published module exports `defineNuxtModule({ meta, defaults, setup })`. `meta.name` identifies it (and makes it install once), `meta.configKey` names its options key in `nuxt.config.ts`, and `meta.compatibility` declares the Nuxt range. `setup(options, nuxt)` runs in Node at build time. It resolves files with `createResolver(import.meta.url)` and registers runtime code: `addPlugin` for a Nuxt plugin (prepended before the app's own), `addImports` for an auto-imported composable, `addComponent` for a component, `addServerHandler` for a Nitro route. Module options do not exist at runtime, so setup copies what runtime code needs into `nuxt.options.runtimeConfig`, merging with `defu` so user values win: public settings under `runtimeConfig.public`, the API key under a private key the environment can fill. Runtime files inside a published package get no auto-imports, so they import from `#imports`.
code
ts · 19 lines// src/module.ts
import { defineNuxtModule, createResolver, addPlugin, addImports, addComponent, addServerHandler } from '@nuxt/kit'
import { defu } from 'defu'
export default defineNuxtModule({
meta: { name: 'nuxt-pageview-analytics', configKey: 'pageviewAnalytics', compatibility: { nuxt: '>=4.0.0' } },
defaults: { endpoint: '/api/_pageview', sampleRate: 1 },
setup (options, nuxt) {
const resolver = createResolver(import.meta.url)
const rc = nuxt.options.runtimeConfig
rc.public.pageviewAnalytics = defu(rc.public.pageviewAnalytics, { endpoint: options.endpoint, sampleRate: options.sampleRate })
rc.pageviewAnalytics = defu(rc.pageviewAnalytics, { apiKey: '' }) // private, set via env
addPlugin(resolver.resolve('./runtime/plugin.client'))
addImports({ name: 'useTrackEvent', from: resolver.resolve('./runtime/app/composables/useTrackEvent') })
addComponent({ name: 'AnalyticsOptOut', filePath: resolver.resolve('./runtime/app/components/AnalyticsOptOut.vue') })
addServerHandler({ route: options.endpoint, handler: resolver.resolve('./runtime/server/pageview.post') })
},
})go deeper
Recall that a module is defined with defineNuxtModule and has meta, defaults and setup, and that setup registers plugins, composables and components.
Explain how options merge, why paths are resolved with createResolver, and what each kit helper registers and where it runs.
Show how you move options to runtime safely, public versus private, and how a published module avoids auto-import, naming and double-install pitfalls.
Decide what your organisation packages as shared Nuxt modules, how they are versioned against Nuxt releases, and which options teams may override.
## Two halves of a module A Nuxt module has code that runs at **two different times**: - **Module code** (`src/module.ts`) runs in Node while `nuxt dev` or `nuxt build` starts. It can read and change `nuxt.options` and register things. It never runs in the browser or in the production server. - **Runtime code** (`src/runtime/`) is what the module adds to the app: plugins, composables, components, server routes. It is bundled into the user's app like their own code. The classic interview trap is to expect module options to be readable in runtime code. They are not, unless the module puts them somewhere runtime code can read. ## The definition `defineNuxtModule` from `@nuxt/kit` takes an object: | Field | Purpose | |---|---| | `meta.name` | identity; with it the module installs only once | | `meta.configKey` | the top-level key in `nuxt.config.ts` for options (defaults to the name) | | `meta.compatibility` | the Nuxt range, such as `{ nuxt: '>=4.0.0' }`; outside it the module is skipped with a warning | | `defaults` | option defaults, as an object or a function of the Nuxt instance | | `hooks` | Nuxt build hooks to register without writing them in setup | | `moduleDependencies` | other modules to install or configure first | | `setup(options, nuxt)` | the work; may be `async`, and Nuxt warns if it takes over five seconds | `options` in `setup` is the merge of inline tuple options, the config key and `defaults`. ## Registering runtime pieces All kit helpers take file paths, which you resolve relative to the module file with `createResolver(import.meta.url)`, because the module will run from `node_modules` in someone else's project. 1. **`addPlugin(resolver.resolve('./runtime/plugin.client'))`** registers a Nuxt plugin. The `.client` part of the file name limits it to the browser. Module plugins are **prepended**, so they run before the app's own plugins; pass `{ append: true }` to change that. 2. **`addImports({ name: 'useTrackEvent', from: resolver.resolve('./runtime/app/composables/useTrackEvent') })`** makes a composable auto-importable in the app. Server code needs `addServerImports` instead. 3. **`addComponent({ name: 'AnalyticsOptOut', filePath: resolver.resolve('./runtime/app/components/AnalyticsOptOut.vue') })`** makes a component usable in templates without an import. Nuxt's docs ask for app-side runtime files under `runtime/app/` so they are type-checked in the app context. 4. **`addServerHandler({ route: '/api/_pageview', handler: resolver.resolve('./runtime/server/pageview.post') })`** adds a Nitro route; the `.post` in the file name limits it to POST. Prefix everything you add (`useTrackEvent`, `AnalyticsOptOut`, `/api/_pageview`) so it does not collide with the app's own names. ## Getting options to runtime Nuxt's documented pattern is to write into `runtimeConfig` with `defu`, so values the user set explicitly win over the module's: - **Public, browser-safe options** such as the endpoint and sample rate go under `nuxt.options.runtimeConfig.public.pageviewAnalytics`. - **The API key** goes under the private `nuxt.options.runtimeConfig.pageviewAnalytics`, with an empty default, so production fills it through `NUXT_PAGEVIEW_ANALYTICS_API_KEY` and only the server route reads it. Copying the whole options object into `public` is the bug to avoid: it publishes the key in every server-rendered page. ## Runtime code in a published package Nuxt does not auto-import inside `node_modules`, so the module's runtime files cannot rely on the app's auto-imports. They import explicitly, for example `import { defineNuxtPlugin, useRuntimeConfig } from '#imports'`. A local module inside the app's `modules/` directory can import helpers from `nuxt/kit` without adding `@nuxt/kit` as a dependency; a published module depends on `@nuxt/kit`. ## Package layout A typical layout for the analytics module: | Path | Registered with | Runs | |---|---|---| | `src/module.ts` | the consumer's `modules` array | Node, at build time | | `src/runtime/plugin.client.ts` | `addPlugin` | browser, at app creation | | `src/runtime/app/composables/useTrackEvent.ts` | `addImports` | wherever a component calls it | | `src/runtime/app/components/AnalyticsOptOut.vue` | `addComponent` | in templates | | `src/runtime/server/pageview.post.ts` | `addServerHandler` | Nitro, per POST request | The server route is where the private key is used: the client plugin posts events to the module's endpoint, and the route reads `useRuntimeConfig(event).pageviewAnalytics.apiKey` and forwards them. The browser never needs the key. ## Testing and versioning - Keep `meta.compatibility` honest; a module that silently breaks on a new Nuxt minor costs every consumer. - Test the module by building a small fixture app that installs it, since most failures show up only when Nuxt resolves paths and templates. - Treat option names and the endpoint path as public API; renaming them is a breaking change for every app that set them. ## Checklist - `meta.name` and `configKey` set, with typed options. - Paths resolved from `import.meta.url`. - Secrets private, public options merged with `defu`. - Runtime files import from `#imports`. - Exports prefixed.
- Why does the module's runtime plugin import defineNuxtPlugin from #imports instead of relying on auto-imports?Auto-imports are not applied to files inside `node_modules`, where a published module ends up, for performance reasons. `#imports` is the virtual module that re-exports everything Nuxt would auto-import, so an explicit import from it works in any consuming app and keeps types correct.
- What happens if two modules both install nuxt-pageview-analytics?`defineNuxtModule` records each module's `meta.name` (or config key) when it installs; a second install with the same name returns early without running setup again. That is why a module should always set `meta.name`: without it, duplicate registrations run twice and register everything twice.
- How would the module add a composable that server routes can use?`addImports` extends the Vue app's auto-imports only. For Nitro code, use `addServerImports` (or `addServerImportsDir` for a folder), which adds entries to the server's import list, so server routes in the consuming app can call the helper without importing it.
saying these in an interview costs you the question
- Module options are available at runtime through useRuntimeConfig automatically.
- A module's setup function runs in the browser when the app starts.
- Runtime files in a published module can rely on the app's auto-imports.
- addImports makes a composable available in Nitro server routes too.
- Plugins added with addPlugin run after all of the app's own plugins by default.
- Copying all module options into runtimeConfig.public is the recommended way to expose them.