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.
defu's merge rules rather than guessing at Nuxt's.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.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.| Package | Role in a module or layer |
|---|---|
hookable | Nuxt's hook system: nuxt.hook, nuxt.callHook, nitro.hooks, nuxtApp.hooks |
unctx | Async context, so useNuxt() and useNuxtApp() work without passing instances around |
unimport | The auto-import registry and transform; addImports* feeds it, #imports is its virtual entry |
unplugin | One plugin implementation for Vite, webpack and Rspack; used by addBuildPlugin |
unstorage | Key-value storage with drivers; backs useStorage() and every Nitro cache |
unhead | Head management: useHead, useSeoMeta, useHeadSafe, tag deduplication and ordering |
defu | Deep defaults — user over layer over module over framework, arrays concatenated |
destr | Safe JSON parsing and env-value coercion (no JSON.parse throw, no prototype pollution) |
ofetch | $fetch: JSON by default, typed responses, retries, interceptors, works everywhere |
ufo | URL utilities that get edge cases right: joinURL, withQuery, withTrailingSlash, parseURL |
pathe | POSIX path utilities — use instead of node:path so paths are identical on Windows |
mlly | ESM utilities: resolvePath, findExports, importModule, used when you must inspect a dependency |
pkg-types | readPackageJSON, resolvePackageJSON — read a consumer's Nuxt version or your own at setup |
knitwork | Safe code generation for templates: genImport, genExport, genObjectFromRaw, genString |
scule | Case conversion: pascalCase, kebabCase, camelCase for component and config names |
magic-string | Source-map-safe string edits in a transform (overwrite, prepend, generateMap) |
consola | Logging; kit's useLogger() wraps it and tags output with your module's name |
c12 | Config loading: nuxt.config, layer extends, .env, $env overrides |
untyped | The schema layer: $default / $resolve for every Nuxt option, plus generated types |
ohash | Stable hashing — cache keys, useAsyncData payload keys, build-artefact fingerprints |
rou3 / radix3 | The radix-tree router behind Nitro route matching and route rules |
jiti | Runtime TypeScript/ESM loading; how nuxt.config.ts and local modules are imported at build time |
giget | Downloading templates and remote layers (nuxi init, git-sourced extends) |
nypm | Package-manager detection and installs, used by nuxt module add |
changelogen | Conventional-commit changelogs and version bumps for releasing a module |
mkdist / unbuild / tsdown | Build tooling: mkdist copies runtime/ file-by-file, unbuild/tsdown bundle src/ |
The build half of a module is mostly these packages doing small jobs:
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:
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:
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] },
)
}
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.
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.
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.
+ and rewrite it with knitwork; derive every component name with scule and every path with pathe.pkg-types' readPackageJSON to log the host app's Nuxt version at setup, and warn if it is below your module's minimum.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.pnpm why unctx and pnpm why unhead in a playground and write down the majors you actually have."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."
Nitro and h3
The server engine underneath Nuxt — presets and .output, route rules, cached handlers, storage, per-request state, server plugins, and how a module extends all of it.
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