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

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.