Templates are how a module turns build-time knowledge into runtime code. Instead of shipping a value through the payload, you write the module that contains it, and the bundler treats it like any other source file: analysed, tree-shaken, cached. Types are the same idea applied to the editor — a module that generates code and no declarations is a module whose consumers get any, which for an internal toolkit is the difference between adoption and resentment.
addTemplate({ filename, getContents, write?, dst? }). The kit reference gives the full field list as src, filename, dst, options, getContents, write and dependsOn — dependsOn declares watched inputs the template's output depends on beyond nuxt.options and the resolved app structure. getContents receives { nuxt, app, options } and returns a string (or a promise of one). Templates land in buildDir (.nuxt) and are virtual by default — held in memory and served to the bundler — so they never hit disk unless you set write: true for a tool that needs a real file (a linter, tsc, an external script).#build/<filename>. filename: 'toolkit/routes.mjs' is imported as #build/toolkit/routes.mjs. Keep everything under one folder so the updateTemplates filter and any debugging stay easy.addTypeTemplate(template, context?) registers a template and adds a reference to it from the generated declaration files. The kit reference types it as addTypeTemplate(template: NuxtTypeTemplate | string, context?: { nitro?: boolean, nuxt?: boolean }) — those two context keys and no others, despite older guides listing more — because Nuxt generates separate tsconfigs and declaration entry points per context. Omit it and the reference goes where Nuxt puts it by default; pass { nitro: true } when the types are for server code.addServerTemplate({ filename, getContents }) writes a virtual module for Nitro, not for the app bundle. A #build/… import does not exist in the Nitro context, so server-side generated code goes through this helper (it lands in nitro.virtual) and is imported by the virtual id you gave it.updateTemplates({ filter }) regenerates templates during dev. Call it from builder:watch with a filter that matches only your own filenames; without a filter you regenerate the whole app on every save.app:templates, so getContents must be cheap and deterministic. Do the expensive scan once, keep the result in a variable your template closes over, and refresh it from builder:watch.genImport, genExport, genObjectFromRawEntries, genString produce valid, correctly escaped JavaScript. Windows paths and apostrophes in filenames are exactly the inputs that break hand-rolled template literals. Use scule (pascalCase, camelCase, kebabCase) for identifier names so my-widget.vue becomes MyWidget the same way Nuxt does it.{ provide: { toolkit } } gives useNuxtApp().$toolkit its type from the plugin's own file, so the return type must be inferrable (no any, no circular import). For a plugin generated by a template, the declaration is only emitted if the template is registered as a plugin at type-generation time — a known edge case that shows up as a working $toolkit with no completion.src/module.ts. @nuxt/module-builder reads ModuleOptions, ModuleHooks, ModuleRuntimeConfig and ModulePublicRuntimeConfig and emits the nuxt/schema augmentation into dist/types.d.mts, so consumers get a typed toolkit: { … } key in nuxt.config without you writing declare module by hand.nuxt typecheck runs vue-tsc against the generated tsconfigs. Run it on the playground in CI: it is the only check that proves your generated declarations actually compile in a consuming app, which is what a team layer's users will experience.A template that scans a directory, plus its declaration:
import { addTemplate, addTypeTemplate, createResolver, defineNuxtModule, resolveFiles, updateTemplates } from '@nuxt/kit'
import { genExport, genObjectFromRawEntries, genString } from 'knitwork'
import { pascalCase } from 'scule'
import { basename, extname, join } from 'pathe'
export default defineNuxtModule({
meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit' },
async setup (_options, nuxt) {
const { resolve } = createResolver(import.meta.url)
const routesDir = join(nuxt.options.rootDir, 'toolkit-routes')
let files = await resolveFiles(routesDir, '**/*.ts')
addTemplate({
filename: 'toolkit/routes.mjs',
getContents: () => {
const entries = files.map(f => [pascalCase(basename(f, extname(f))), genString(f)] as [string, string])
return `export const routes = ${genObjectFromRawEntries(entries)}`
},
})
addTypeTemplate({
filename: 'types/toolkit-routes.d.ts',
getContents: () => [
`declare module '#build/toolkit/routes.mjs' {`,
` export const routes: Record<string, string>`,
`}`,
].join('\n'),
}, { nuxt: true })
nuxt.hook('builder:watch', async (_event, path) => {
if (!path.includes('toolkit-routes/')) { return }
files = await resolveFiles(routesDir, '**/*.ts')
await updateTemplates({ filter: t => t.filename.startsWith('toolkit/') })
})
},
})
The runtime side imports it like any module — and the bundler tree-shakes what nobody uses:
import { routes } from '#build/toolkit/routes.mjs'
export function useToolkitRoutes () {
return routes
}
A server-side generated module, for code Nitro needs:
addServerTemplate({
filename: '#toolkit/manifest',
getContents: () => `export const manifest = ${JSON.stringify({ version: '1.4.0' })}`,
})
The exports module-builder turns into the nuxt/schema augmentation:
export interface ModuleOptions { apiBase: string, theme: 'default' | 'compact' }
export interface ModuleHooks { 'toolkit:extend': (registry: ToolkitRegistry) => void }
export interface ModuleRuntimeConfig { toolkit: { apiToken: string } }
export interface ModulePublicRuntimeConfig { toolkit: { apiBase: string } }
Templates regenerate on app:templates, which fires on every rebuild — in dev that is every save. If getContents walks the file system or parses files, you have added that cost to every keystroke-triggered rebuild. Scan once in setup, close over the result, and refresh it explicitly from builder:watch before calling updateTemplates.
#build/<filename> is an alias of the app bundle. A server handler importing it resolves in dev and fails in the built output. Use addServerTemplate for anything the server needs, or generate both and keep the contents in a shared function. The same split explains addTypeTemplate's { nuxt, nitro } context: Nuxt 4 writes per-context tsconfigs, and a declaration referenced from the wrong one is simply not visible.
`export const path = '${file}'` produces 'C:\Users\…', where \U is an invalid escape. Every generated-code bug of this class disappears with genString(file), and genObjectFromRawEntries does the same for keys that are not valid identifiers. Interviewers who have shipped a module will recognise this immediately.
#build/toolkit/routes.mjs from the files in playground/toolkit-routes/ using resolveFiles, knitwork and scule. Watch the directory with builder:watch and updateTemplates, add a file while dev runs, and confirm the module updates without a restart.pnpm dev:prepare and open .nuxt/nuxt.d.ts and .nuxt/types/ to see where the reference landed. Confirm completion in the playground, then remove the { nuxt: true } context and see what changes.ModuleOptions and ModulePublicRuntimeConfig from src/module.ts, run pnpm prepack, and read the generated dist/types.d.mts. Then run nuxt typecheck on the playground and make it part of CI."I export ModuleOptions, ModuleHooks, ModuleRuntimeConfig and ModulePublicRuntimeConfig from src/module.ts, and @nuxt/module-builder emits the nuxt/schema augmentation into dist/types.d.mts from them, so a consumer gets a typed toolkit key in nuxt.config and typed useRuntimeConfig(). For anything I generate, I use addTypeTemplate, which registers the template and adds the reference to the generated declarations; its second argument picks the context, { nuxt } or { nitro }, because Nuxt 4 writes separate tsconfigs per context and a declaration referenced from the wrong one is invisible. I generate the code itself with knitwork rather than template literals so paths and quotes are escaped properly, and I run nuxt typecheck on the playground in CI — that is the only check that proves the declarations compile in a real consuming app."
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.
Dependencies and composition
installModule versus the declarative moduleDependencies field, detecting other modules with hasNuxtModule, choosing dependency versus peerDependency, and cooperating through hooks.