Under the hood

Auto-imports and how they bite

How unimport builds two separate registries and rewrites your identifiers at build time, why module runtime code must import from

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.

Know

  • A registry, not a global. unimport collects a list of { 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).
  • A transform rewrites identifiers. At build time unimport walks each module's source, finds bare identifiers that match a registry entry and are not otherwise bound, and prepends a real import statement. Nothing is attached to globalThis.
  • That is why tree-shaking works. An auto-import you never write is never imported, so it never enters the graph — auto-imports are free at runtime and cost only a little build time. It is also why you can shadow one with a local 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.
  • Hence the rule for authors: every file in a module's or layer's 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.
  • Two registries, two module graphs. The app registry is for the Vue side; the Nitro registry is for server handlers, server middleware and server plugins. A composable registered with 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.
  • Collisions. Two sources registering the same name produce a warning and one winner. 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.
  • Extension hooks. 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".
  • Directories. 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.

How it works

The transform is easiest to see by turning it off. With autoImport: false the first file breaks and the second does not:

nuxt.config.ts
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 },
  ],
})
src/runtime/composables/useToolkit.ts
// 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:

src/module.ts
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:

src/imports.ts
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'
    }
  })
}
Gotcha· It works in a component, it throws in a server route

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.

Gotcha· Silent component overrides between layers

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Register 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.
  • Register a composable named useState on purpose. Read the collision warning, then fix it twice: once by renaming with as, once with priority.
  • Set imports: { autoImport: false } in the playground. Note which of your module's runtime files break, and convert them all to explicit #imports.
  • Open .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.

Be able to say

Be able to say· How do auto-imports actually work, and how can they break a module?

"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."