Auto-imports look like magic and are therefore assumed to be expensive, global or untyped. They are none of those things — they are a build-time transform over a registry — and the interview value is in the consequences: why unused auto-imports cost nothing, why your module's runtime code must not rely on them, and why the same function name works in a component and throws in a server route.
{ name, from, as } entries. For the app it scans app/composables/** and app/utils/** (plus each layer's equivalents); for Nitro it scans server/utils/**. Modules add entries with addImports / addImportsDir (app) and addServerImports / addServerImportsDir (Nitro).import statement. Nothing is attached to globalThis.const useFetch = … and the transform leaves you alone.#imports is the virtual module that re-exports the whole registry. It is generated and typed regardless of the transform, so it keeps working when a consumer sets imports: { autoImport: false } — which disables only the identifier rewriting, not the registry.runtime/ directory imports explicitly from #imports. Relying on the transform means your code breaks for any consumer who turns it off, and it makes your source harder to read for anyone who does not already know your registry.addImports does not exist in a server route — you get a build-time "is not defined" from the transform or a runtime ReferenceError, not a helpful message. Register a server-side equivalent with addServerImports.shared/ is the bridge. Nuxt 4's root-level shared/ directory holds code meant for both sides and is reachable through the #shared alias, which is the right home for pure helpers a toolkit needs in a component and in a handler.addImports and imports.presets entries accept a priority — higher wins — which is how a layer deliberately overrides a base layer's composable. The same applies to components via addComponent({ priority }), where the name is what collides, and a layer's MyButton.vue silently replaces a lower-priority layer's.imports:extend receives the assembled array and lets a module push, filter or re-map entries after scanning; imports:sources lets you add whole presets (a package's exports registered in one go). Use imports:extend for surgery, imports:sources for "expose this library".imports.dirs adds scan directories, components.dirs (or the components:dirs hook) adds component directories with prefix, pathPrefix, global and priority. Because these are arrays merged by defu, every layer's directories are concatenated onto the list — a layer's app/composables is scanned automatically once the layer is in the chain.The transform is easiest to see by turning it off. With autoImport: false the first file breaks and the second does not:
export default defineNuxtConfig({
imports: {
autoImport: false, // identifiers are no longer rewritten; #imports still exists
dirs: ['app/state'], // extra scan directories, concatenated across layers
},
components: [
{ path: '~/components/ui', prefix: 'Ui', pathPrefix: false },
],
})
// Correct for a module or layer: explicit, works with autoImport on or off.
import { computed } from 'vue'
import { useRuntimeConfig, useState } from '#imports'
export function useToolkit() {
const config = useRuntimeConfig().public.toolkit
const open = useState('toolkit:open', () => false)
return { open, endpoint: computed(() => config.endpoint) }
}
Registering into both registries, with a priority high enough to beat a base layer:
import { defineNuxtModule, createResolver, addImports, addServerImports, addComponent } from '@nuxt/kit'
export default defineNuxtModule({
meta: { name: '@acme/toolkit' },
setup() {
const resolver = createResolver(import.meta.url)
// app registry — components, pages, app plugins
addImports({
name: 'useToolkit',
as: 'useToolkit',
from: resolver.resolve('./runtime/composables/useToolkit'),
priority: 10, // higher priority wins over a base layer's useToolkit
})
// Nitro registry — server routes, server middleware, server plugins.
// Same name, DIFFERENT implementation: no Vue reactivity on this side.
addServerImports([
{ name: 'useToolkitServer', from: resolver.resolve('./runtime/server/utils/toolkit') },
])
addComponent({
name: 'ToolkitBadge',
filePath: resolver.resolve('./runtime/components/ToolkitBadge.vue'),
priority: 10,
})
},
})
The two hooks, for the cases the helpers do not cover:
import type { Nuxt } from '@nuxt/schema'
export function registerImportHooks(nuxt: Nuxt) {
nuxt.hook('imports:sources', (presets) => {
// expose a dependency's exports as auto-imports in one go
presets.push({ from: '@acme/date-utils', imports: ['formatDate', 'parseDate'] })
})
nuxt.hook('imports:extend', (imports) => {
// surgery after scanning: rename ours out of the way of a known conflict
for (const i of imports) {
if (i.name === 'useAuth' && i.from.includes('@acme/toolkit')) i.as = 'useToolkitAuth'
}
})
}
This is the single most reported bug against toolkits that register composables. addImports populates the app registry only, so server/api/thing.get.ts never sees useToolkit — the consumer gets useToolkit is not defined with a stack in generated code. Ship a deliberate server-side counterpart via addServerImports (a different implementation: no ref, no Nuxt app context, takes the event), put genuinely shared pure helpers in shared/ behind #shared, and document which side each export belongs to. Do not "fix" it by importing your app composable from a handler — you will pull Vue reactivity into the server bundle.
Component registration collides on the resolved name, not the path. A base layer's components/Card.vue and a feature layer's components/ui/Card.vue both resolve to Card unless pathPrefix says otherwise, and the higher-priority layer simply wins with no warning at build time. Prefix a toolkit's components (prefix: 'Acme') so the namespace is yours, and use nuxi devtools or the components template in .nuxt to see what actually got registered.
Confirm exactly which subdirectories of shared/ are auto-imported into which context in the current minor (shared/utils and shared/types are the documented ones), and whether the #shared alias is the recommended way to reach the rest. Also re-read the default priority value on the auto-imports page before quoting a number.
useToolkit with addImports, call it from a page (works), then call it from server/api/ping.get.ts and read the exact error. Add an addServerImports equivalent and make the route pass.useState on purpose. Read the collision warning, then fix it twice: once by renaming with as, once with priority.imports: { autoImport: false } in the playground. Note which of your module's runtime files break, and convert them all to explicit #imports..nuxt/imports.d.ts and .nuxt/types/nitro-imports.d.ts and diff the two lists — that is the clearest possible picture of the two registries."unimport builds a registry of name-to-source entries — scanned from app/composables, app/utils and server/utils, plus whatever modules add — and a build-time transform rewrites matching bare identifiers into real import statements. Nothing is global, so an auto-import you never use is never bundled. Two things bite module authors. First, consumers can set imports.autoImport: false, which disables the rewriting but keeps the #imports virtual module, so any runtime code I ship imports explicitly from #imports rather than relying on the transform. Second, there are two separate registries: addImports targets the Vue app and addServerImports targets Nitro, so a composable I register for the app simply does not exist in a server route and the consumer gets an undefined-function error. Collisions are resolved by priority, which is also how a layer deliberately overrides a base layer's composable or component."
The unjs toolbelt
The unjs packages Nuxt is assembled from, what each one does for a module or layer author, and where you actually meet them while writing a toolkit.
Vite, unplugin and build-time transforms
The Vite plugin hooks a module author uses, how addBuildPlugin and unplugin give one implementation for every builder, source-map-safe edits with magic-string, and dev-server hygiene.