Under the hood

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.

Nuxt is less a framework than an opinionated assembly of small, independently versioned unjs packages. Knowing the names is worth a surprising amount in an interview, because it changes how you answer every other question: "how does config merging work" stops being Nuxt lore and becomes "c12 loads, defu merges, untyped resolves", which is checkable, debuggable and portable. For a toolkit author it also matters practically — half of these are direct dependencies of any non-trivial module.

Know

  • Nuxt's own code is mostly glue. When something surprises you, the answer usually lives in a small package with its own README and its own tests — read defu's merge rules rather than guessing at Nuxt's.
  • Version the ones you depend on deliberately. A module that imports defu, pathe, ufo or scule directly should list them as dependencies, not rely on hoisting from nuxt. Kit re-exports some of them; anything it does not, you own.
  • Most of them are runtime-agnostic. ofetch, ufo, destr, defu and ohash run in the browser, in Node and in a worker, which is why they are safe in a module's runtime/ directory. c12, jiti, mlly, pkg-types, giget and nypm are build-time only and must never be imported from runtime code.

The toolbelt

PackageRole in a module or layer
hookableNuxt's hook system: nuxt.hook, nuxt.callHook, nitro.hooks, nuxtApp.hooks
unctxAsync context, so useNuxt() and useNuxtApp() work without passing instances around
unimportThe auto-import registry and transform; addImports* feeds it, #imports is its virtual entry
unpluginOne plugin implementation for Vite, webpack and Rspack; used by addBuildPlugin
unstorageKey-value storage with drivers; backs useStorage() and every Nitro cache
unheadHead management: useHead, useSeoMeta, useHeadSafe, tag deduplication and ordering
defuDeep defaults — user over layer over module over framework, arrays concatenated
destrSafe JSON parsing and env-value coercion (no JSON.parse throw, no prototype pollution)
ofetch$fetch: JSON by default, typed responses, retries, interceptors, works everywhere
ufoURL utilities that get edge cases right: joinURL, withQuery, withTrailingSlash, parseURL
pathePOSIX path utilities — use instead of node:path so paths are identical on Windows
mllyESM utilities: resolvePath, findExports, importModule, used when you must inspect a dependency
pkg-typesreadPackageJSON, resolvePackageJSON — read a consumer's Nuxt version or your own at setup
knitworkSafe code generation for templates: genImport, genExport, genObjectFromRaw, genString
sculeCase conversion: pascalCase, kebabCase, camelCase for component and config names
magic-stringSource-map-safe string edits in a transform (overwrite, prepend, generateMap)
consolaLogging; kit's useLogger() wraps it and tags output with your module's name
c12Config loading: nuxt.config, layer extends, .env, $env overrides
untypedThe schema layer: $default / $resolve for every Nuxt option, plus generated types
ohashStable hashing — cache keys, useAsyncData payload keys, build-artefact fingerprints
rou3 / radix3The radix-tree router behind Nitro route matching and route rules
jitiRuntime TypeScript/ESM loading; how nuxt.config.ts and local modules are imported at build time
gigetDownloading templates and remote layers (nuxi init, git-sourced extends)
nypmPackage-manager detection and installs, used by nuxt module add
changelogenConventional-commit changelogs and version bumps for releasing a module
mkdist / unbuild / tsdownBuild tooling: mkdist copies runtime/ file-by-file, unbuild/tsdown bundle src/

How it works

The build half of a module is mostly these packages doing small jobs:

src/module.ts
import { defineNuxtModule, createResolver, useLogger, addComponent } from '@nuxt/kit'
import { readPackageJSON } from 'pkg-types'
import { pascalCase, kebabCase } from 'scule'
import { joinURL } from 'ufo'
import defu from 'defu'

export default defineNuxtModule<ToolkitOptions>({
  meta: { name: '@acme/toolkit', configKey: 'toolkit' },
  defaults: { prefix: 'Acme', apiBase: '/api' },
  async setup(options, nuxt) {
    const resolver = createResolver(import.meta.url)
    const logger = useLogger('toolkit')                       // consola

    const { version } = await readPackageJSON('nuxt', { url: nuxt.options.rootDir })
    logger.info(`host Nuxt ${version}`)                        // pkg-types

    nuxt.options.runtimeConfig.public.toolkit = defu(          // defu
      nuxt.options.runtimeConfig.public.toolkit,
      { endpoint: joinURL(options.apiBase, 'v1') },            // ufo
    )

    for (const name of ['button', 'data-table']) {
      addComponent({
        name: options.prefix + pascalCase(name),               // scule
        filePath: resolver.resolve(`./runtime/components/${kebabCase(name)}.vue`),
      })
    }
  },
})

When a module generates a file, string concatenation is where the bugs are — knitwork escapes for you:

src/templates.ts
import { genImport, genObjectFromRaw, genString } from 'knitwork'
import { relative } from 'pathe'
import { pascalCase } from 'scule'

export function routeRegistryTemplate(routes: { name: string, file: string }[], buildDir: string) {
  return [
    ...routes.map(r => genImport(`./${relative(buildDir, r.file)}`, pascalCase(r.name))),
    `export const registry = ${genObjectFromRaw(
      Object.fromEntries(routes.map(r => [genString(r.name), pascalCase(r.name)])),
    )}`,
  ].join('\n')
}

The runtime half is a different, smaller set — everything here must work in a browser and in a worker:

src/runtime/composables/useToolkit.ts
import { destr } from 'destr'
import { withQuery } from 'ufo'
import { hash } from 'ohash'
import type { Ref } from 'vue'
import { useRuntimeConfig, useAsyncData } from '#imports'

export function useToolkitSearch(query: Ref<string>) {
  const { endpoint, flags } = useRuntimeConfig().public.toolkit
  // env-provided values arrive as strings; destr coerces without throwing
  const { fuzzy } = destr<{ fuzzy?: boolean }>(flags) ?? {}
  // ohash gives a stable key, so two components asking the same question
  // share one request and one payload entry
  return useAsyncData(
    `toolkit:search:${hash({ endpoint, fuzzy })}`,
    () => $fetch<SearchHit[]>(withQuery(endpoint, { q: query.value, fuzzy })),
    { watch: [query] },
  )
}

Where you actually meet them

In practice the encounters cluster. Writing the module's setup: pathe and createResolver for every path (never node:path — a Windows consumer will report a broken alias), defu for merging options and runtime config, pkg-types to read the host's versions, consola through useLogger so your output is tagged and respects the consumer's log level. Generating code: knitwork and scule, with magic-string the moment you move from generating a file to rewriting one. Debugging a config surprise: c12 and untyped — the layer chain and the $resolve that turned your value into something else. Debugging an auto-import surprise: unimport. Writing server code: unstorage, ofetch, rou3. Releasing: unbuild or tsdown for src/, mkdist for runtime/ (it must stay unbundled, file-per-file, so consumers' builds can transform it), changelogen for the changelog.

Gotcha· node:path in a module breaks Windows consumers

node:path produces \ separators on Windows, and those paths end up in aliases, template contents and Vite include patterns where only / works — so the module builds fine on your Mac and fails in a colleague's dev server with "failed to resolve import". Use pathe, which is a drop-in replacement with POSIX semantics everywhere, and prefer createResolver(import.meta.url).resolve() over manual joins.

Verify before the interview:

Major versions here move independently of Nuxt: confirm which major of unctx, unhead and unstorage Nuxt 4.5 ships, and whether radix3 or rou3 is the router in your installed Nitro. pnpm why <package> in a playground answers all of these in one command, and is a better answer in an interview than a memorised number.

docs ↗

Exercise

Exercise
  • Take a template in your own module that builds a string with + and rewrite it with knitwork; derive every component name with scule and every path with pathe.
  • Use pkg-types' readPackageJSON to log the host app's Nuxt version at setup, and warn if it is below your module's minimum.
  • Open the package.json of @nuxt/image and @nuxtjs/i18n and classify every unjs dependency against the table above. Note which ones are dependencies and which are devDependencies, and work out why.
  • Run pnpm why unctx and pnpm why unhead in a playground and write down the majors you actually have.

Be able to say

Be able to say· Nuxt depends on a lot of small unjs packages. Which ones do you use directly, and for what?

"Day to day I use defu for option and runtime-config merging because it gives me the user-over-layer-over-default precedence for free, pathe for every path so Windows consumers do not get backslashes in generated code, scule for component and config naming, ufo for URL building, knitwork when I generate a template and magic-string when I rewrite existing source with a source map. pkg-types reads the host's versions at setup so I can warn instead of crashing. On the runtime side it is ofetch, destr, ohash for stable keys and unstorage behind Nitro's cache. Knowing the split matters because the build-time ones — c12, jiti, mlly, pkg-types — must never be imported from runtime/; that is one of the classic ways a module breaks a consumer's client bundle."