Module authoring

Templates and types

Generating code into .nuxt with addTemplate, adding declarations with addTypeTemplate, regenerating only your own templates on watch, and getting the nuxt/schema augmentation right.

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.

Know

  • addTemplate({ filename, getContents, write?, dst? }). The kit reference gives the full field list as src, filename, dst, options, getContents, write and dependsOndependsOn 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).
  • Import a template as #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.
  • Templates regenerate on 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.
  • Generate code with knitwork, not string concatenation: 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.
  • Typing what a plugin provides: a plugin returning { 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.
  • Export your interfaces from 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.

How it works

A template that scans a directory, plus its declaration:

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

src/runtime/app/composables/useToolkitRoutes.ts
import { routes } from '#build/toolkit/routes.mjs'

export function useToolkitRoutes () {
  return routes
}

A server-side generated module, for code Nitro needs:

src/module.ts
addServerTemplate({
  filename: '#toolkit/manifest',
  getContents: () => `export const manifest = ${JSON.stringify({ version: '1.4.0' })}`,
})

The exports module-builder turns into the nuxt/schema augmentation:

src/module.ts
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 } }
Gotcha· getContents is called more often than you think

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.

Gotcha· #build does not exist in Nitro

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

Gotcha· Hand-written string templates break on Windows

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

Exercise

Exercise
  • Generate #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.
  • Add a type template declaring the shape of that export. Run 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.
  • Export 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.

Be able to say

Be able to say· How do you type a module's options and its runtime config for consumers?

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