src/module.ts and src/runtime/** sit in the same repository, are written in the same language and are built by the same command — and they run in different processes, on different machines, at different times. Every rule on this page follows from that one fact. Interviewers probe it with a single question: "why can't runtime/ import @nuxt/kit?" The answer tells them whether you have actually shipped a module or only read about one.
@nuxt/kit from src/runtime/**, and never touch nuxt.options. Kit resolves the current Nuxt instance from an unctx context that exists only inside the build process; it also pulls in Node-only dependencies. Bundled into the client you get a resolution failure or a huge chunk, and on the server you get "Nuxt instance is unavailable".#imports (the auto-import registry), #app, vue, vue-router, h3, #build/<template>, and your own runtime/** files. Server runtime additionally has nitropack/runtime on Nuxt 4. Nuxt 5 moves to Nitro v3 and renames all of it Nuxt 5 — the package becomes nitro, types move to nitro/types, h3 helpers to nitro/h3, and the upgrade guide's preferred path for app and module runtime code is nuxt/server, so import { defineEventHandler } from 'nitropack/runtime' becomes import { defineEventHandler } from 'nuxt/server'.#imports rather than relying on auto-imports. A consumer can set imports: { autoImport: false } in nuxt.config, and your module must still work. Writing import { defineNuxtPlugin, useRuntimeConfig } from '#imports' costs nothing and removes the whole class of "works in my playground" bugs.@nuxt/module-builder (mkdist under the hood) transpiles src/runtime file by file rather than bundling it. src/module.ts becomes dist/module.mjs plus dist/module.json and dist/types.d.mts; src/runtime/foo.ts becomes dist/runtime/foo.js plus a foo.d.ts at the same relative path. That one-to-one mapping is why resolver.resolve('./runtime/app/plugins/toolkit') still points at a real file after publishing — the path you resolve in src exists in dist.import { something } from 'some-lib' in a runtime file becomes a real dependency of the consumer's app: it must be a dependency of your package, not a devDependency..client.ts / .server.ts suffixes set a plugin's or component's mode: addPlugin infers mode from the suffix, addComponent({ mode: 'client' }) does it explicitly. Inside a universal file, guard with import.meta.client / import.meta.server; those are compile-time constants, so the dead branch is eliminated per environment bundle. Never process.client — that is Nuxt 2 vocabulary.build.transpile history. Modules used to push resolver.resolve('./runtime') onto nuxt.options.build.transpile so webpack would compile the untranspiled runtime. Nuxt now adds your module's package root to build.transpile automatically, and with Vite it is harmless but rarely needed. You still see it in every older module and will be asked what it was for.runtime/server/** imports #imports, Nitro must treat your directory as its own source, not as an external dependency, otherwise the alias never resolves in the built server. Add it to nitro.externals.inline from the nitro:config hook.app/ and server/ code that Nuxt merges; it has no runtime/ concept and no way to decide which files to inject. That is the line at which a team toolkit stops being a layer and becomes a layer plus a module.The shape of the package, and what nuxt-module-build does with it:
src/ dist/
├─ module.ts → ├─ module.mjs (bundled: kit, node deps, your build logic)
│ ├─ module.json (name + version, read by Nuxt)
│ ├─ types.d.mts (nuxt/schema augmentation)
└─ runtime/ → └─ runtime/ (transpiled file by file, same paths)
├─ app/ ├─ app/
│ ├─ plugins/toolkit.ts ├─ plugins/toolkit.js (+ .d.ts)
│ └─ composables/useToolkit.ts └─ composables/useToolkit.js
└─ server/utils/version.ts └─ server/utils/version.js
A correct runtime plugin — only allowed imports, mode from the filename:
import { defineNuxtPlugin, useRuntimeConfig } from '#imports'
import { features } from '#build/toolkit/features.mjs'
export default defineNuxtPlugin((nuxtApp) => {
// .client suffix: this file is never in the server bundle at all
const { apiBase } = useRuntimeConfig().public.toolkit
const observer = new PerformanceObserver(() => {})
observer.observe({ type: 'navigation', buffered: true })
nuxtApp.hook('app:beforeMount', () => observer.disconnect())
return { provide: { toolkit: { apiBase, features } } }
})
Making Nitro inline the server runtime so #imports resolves in the built output:
import { addServerHandler, addServerImportsDir, createResolver, defineNuxtModule } from '@nuxt/kit'
import { defu } from 'defu'
export default defineNuxtModule({
meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit' },
setup (_options, nuxt) {
const resolver = createResolver(import.meta.url)
addServerImportsDir(resolver.resolve('./runtime/server/utils'))
addServerHandler({ route: '/api/_toolkit/health', handler: resolver.resolve('./runtime/server/api/health') })
nuxt.hook('nitro:config', (nitroConfig) => {
nitroConfig.externals = defu(typeof nitroConfig.externals === 'object' ? nitroConfig.externals : {}, {
inline: [resolver.resolve('./runtime')],
})
})
},
})
In dev, Nitro resolves aliases through Vite and your untranspiled source, so a server util importing #imports looks fine. In nuxt build your package is treated as an external node module and the alias is not rewritten, so the bundle fails or silently ships the wrong thing. The nitro:config inline entry above is the fix, and the test is a real build plus grep -r yourHelperName .output/server/chunks.
Bundling src/module.ts hides mistakes: anything it imports is inlined, so a devDependency works. src/runtime is only transpiled, so its imports remain in the published output and must be real dependencies. The failure appears for the consumer as "Cannot find package …" and never for you, because your node_modules has everything. npm pack, install the tarball into a clean app, and run it.
import.meta.client is replaced at build time, so the other branch is removed from that environment's bundle entirely. Consequences people miss: you cannot toggle it, a const isClient = import.meta.client passed around defeats the elimination, and code inside a guard still has to parse, so a top-level import 'browser-only-lib' next to it is still in the graph. Use a dynamic await import() inside the guard when the dependency itself must not reach the server.
src/runtime/server/utils/toolkitVersion.ts exposed with addServerImportsDir, use it inside a handler registered with addServerHandler, then build the playground and grep -r toolkitVersion playground/.output/server/chunks to confirm it was inlined. Remove the nitro:config hook and watch the build fail.imports: { autoImport: false } in the playground's nuxt.config. Fix every break by importing from #imports explicitly, and note which files you had to touch.useNuxt from @nuxt/kit inside a runtime composable, run pnpm dev, and read the error carefully. Then explain in one sentence why the same import in src/module.ts is fine."Because kit is build-time code. It reaches the current Nuxt instance through an unctx async context that only exists inside the build process, and it depends on Node APIs. In the client bundle the import either fails to resolve or drags the build toolchain into the browser; on the server it throws 'Nuxt instance is unavailable'. Runtime code may import #imports, #app, vue, h3 and my own generated templates from #build, and anything it needs from the build world has to be handed over through runtime config, appConfig or a template. The mechanical reason the boundary holds is that module-builder bundles src/module.ts but only transpiles src/runtime file by file, so runtime imports stay live in the published package — which is also why a runtime dependency must be a real dependency, not a devDependency."
Crossing the boundary
The four channels that carry a module option from the build world into the browser — public and private runtime config, appConfig and generated templates — and how to choose between them.
Hooks
How unjs/hookable powers the three hook families, a goal-to-hook cheat sheet for module authors, and how to declare your own hooks so other modules can cooperate with yours.