Under the hood

Vite, unplugin and build-time transforms

The Vite plugin hooks a module author uses, how addBuildPlugin and unplugin give one implementation for every builder, source-map-safe edits with magic-string, and dev-server hygiene.

Rewriting code at build time is the most powerful thing a module can do and the easiest thing to do badly. Get it right and a toolkit can inject a version constant, strip a debug branch or generate a registry with zero runtime cost. Get it wrong and you break source maps, slow every consumer's dev server, or ship a transform that works in Vite and silently does nothing for the team that switched to Rspack.

Know

  • The plugin hooks you will actually use: config (mutate the config before it is resolved), configResolved (read the final config, e.g. to branch on command === 'build'), resolveId (claim a virtual specifier), load (return its contents), transform (rewrite a real file), transformIndexHtml, and handleHotUpdate (control what a file change invalidates in dev).
  • Ordering is enforce. enforce: 'pre' runs before Vite's core plugins — required if you must see source before the Vue SFC compiler touches it. enforce: 'post' runs after. Everything without enforce runs in registration order in the middle.
  • addVitePlugin(plugin, { dev, build, prepend }) registers into nuxt.options.vite.plugins. dev and build gate when it applies; prepend puts it at the front of the array. Nuxt 5 Its client / server options are deprecated in favour of Vite's Environment API: one shared config with environment-specific plugins, chosen by the plugin itself through applyToEnvironment(env => env.name === 'client'). extendViteConfig is deprecated for the same reason.
  • Wrapping can eat your enforce. When Nuxt wraps a non-isomorphic plugin for a single environment it may apply its own enforce, so a plugin that declared 'pre' can end up running late. If ordering matters, verify it by logging the resolved plugin list rather than trusting the declaration.
  • addBuildPlugin + unplugin is the portable form. unplugin's createUnplugin produces { vite, webpack, rspack, rollup, esbuild } from one implementation, using the unplugin hooks transformInclude, transform, resolveId and load. A toolkit that ships transforms should use it, because the consumer chooses the builder, not you.
  • Never return a plain rewritten string. Use magic-string: s.overwrite, s.prepend, s.append, then return { code: s.toString(), map: s.generateMap({ hires: true }) }. Without the map, every stack trace and breakpoint in the consumer's app points at the wrong line — and they will blame their own code first.
  • vite.define is for compile-time flags, substituted literally before minification so dead branches vanish. Nuxt's defaults include __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false; flipping it to true in a production build is how you get real hydration warnings out of a production bundle (see hydration).
  • Dev-server hygiene. Vite pre-bundles dependencies with optimizeDeps. A CommonJS dependency your module imports from runtime/ that Vite discovers late triggers "new dependencies optimized… reloading", a full page reload, and — if two modules keep discovering each other's deps — a reload loop. Put CJS runtime dependencies in vite.optimizeDeps.include; put large, already-ESM dependencies you do not want pre-bundled in exclude.
  • Builders. Nuxt 4.5 runs Vite 8 on Rolldown, so transforms run in a Rust bundler and the plugin container's behaviour is closer to Rollup's than it used to be. The supported alternative is Rspack via builder: '@nuxt/rspack-builder' — one more reason to write transforms with unplugin.

How it works

One implementation, every builder, with a source map:

src/transforms/version.ts
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'

const TOKEN = '__TOOLKIT_VERSION__'

export const versionPlugin = createUnplugin<{ version: string }>(options => ({
  name: 'toolkit:version',
  enforce: 'pre',
  // cheap filter first: transform() is not even called for files that fail this
  transformInclude: id => /\.(vue|[cm]?[jt]s)$/.test(id) && !id.includes('/node_modules/'),
  transform(code, id) {
    if (!code.includes(TOKEN)) return
    const s = new MagicString(code)
    let index = code.indexOf(TOKEN)
    while (index !== -1) {
      s.overwrite(index, index + TOKEN.length, JSON.stringify(options.version))
      index = code.indexOf(TOKEN, index + TOKEN.length)
    }
    return { code: s.toString(), map: s.generateMap({ source: id, hires: true }) }
  },
}))
src/module.ts
import { defineNuxtModule, addBuildPlugin, addVitePlugin, useLogger } from '@nuxt/kit'
import { readPackageJSON } from 'pkg-types'
import { versionPlugin } from './transforms/version'

export default defineNuxtModule({
  meta: { name: '@acme/toolkit' },
  async setup() {
    const logger = useLogger('toolkit')
    const { version = '0.0.0' } = await readPackageJSON(import.meta.url)

    // works in Vite, webpack and Rspack from the single implementation above
    addBuildPlugin(versionPlugin({ version }))

    // Vite-only, dev-only, and it must run before other plugins
    addVitePlugin({
      name: 'toolkit:dev-banner',
      enforce: 'pre',
      // :v5 environment-aware instead of the deprecated { client, server } options
      applyToEnvironment: env => env.name === 'client',
      configResolved(config) {
        if (config.command === 'serve') logger.info('toolkit dev transforms active')
      },
    }, { dev: true, build: false, prepend: true })
  },
})

A virtual module is resolveId plus load, and it is how a module hands generated code to the app without writing a file:

src/transforms/virtual.ts
import { createUnplugin } from 'unplugin'

const ID = 'virtual:toolkit/routes'
const RESOLVED = '\0' + ID // the \0 prefix tells other plugins to leave it alone

export const routesPlugin = createUnplugin<{ routes: string[] }>(options => ({
  name: 'toolkit:routes',
  resolveId: id => (id === ID ? RESOLVED : undefined),
  load: id => (id === RESOLVED ? `export const routes = ${JSON.stringify(options.routes)}` : undefined),
}))

And the config-side levers:

nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    define: {
      // ship real hydration diagnostics in a staging production build
      __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true',
    },
    optimizeDeps: {
      include: ['@acme/legacy-cjs-sdk'], // pre-bundle it once, not on first import
      exclude: ['@acme/huge-esm-charts'], // leave it alone; it is already ESM
    },
  },
  // builder: '@nuxt/rspack-builder',  // the supported alternative to Vite
})
Gotcha· A transform without a source map makes every stack trace lie

Returning code.replace(...) is tempting because it is two lines. The cost lands on the consumer: line numbers shift, their breakpoints stop matching, and an error thrown in their component points at somebody else's line. Vite will not warn you. Always go through magic-string and return { code, map } — and check it by building the playground with source maps on and setting a breakpoint inside a transformed file.

Gotcha· transform() on every file is a dev-server tax

transform runs for every module in the graph, including node_modules. A regex over the whole file body, per file, per request, per HMR update, is how a toolkit gets a reputation for "making dev slow". Use transformInclude (or Vite's id filter) to reject by extension and path first, then a cheap code.includes(TOKEN) guard before you allocate a MagicString.

Verify before the interview:

The Environment API migration is the fastest-moving thing on this page. Confirm the current status of addVitePlugin's client / server options, whether extendViteConfig and the vite:extendConfig hook are deprecated or removed in your installed version, and the exact spelling of applyToEnvironment / configEnvironment in Vite's plugin docs.

docs ↗

Exercise

Exercise
  • Write the __TOOLKIT_VERSION__ unplugin above, register it with addBuildPlugin, and confirm the literal appears in both the client bundle and .output/server.
  • Break it deliberately: return code.replace(...) with no map, then set a breakpoint in a transformed .vue file and watch it land on the wrong line.
  • Switch the playground to builder: '@nuxt/rspack-builder' and confirm the same transform still runs.
  • Register a pre-enforced Vite plugin with and without prepend: true, and log the resolved plugin order in configResolved to see where it actually ended up.
  • Import a CommonJS dependency from your module's runtime/ directory, watch the "new dependencies optimized… reloading" message, then fix it with vite.optimizeDeps.include.

Be able to say

Be able to say· How would you rewrite code at build time from a module, and what would you be careful about?

"I would write it as an unplugin with createUnplugin and register it with addBuildPlugin, so one implementation covers Vite, Rspack and webpack — the consumer picks the builder, not me. Inside, transformInclude filters by extension and path so transform never runs on node_modules, then a cheap string check before I allocate anything, then magic-string for the edit and generateMap({ hires: true }) for the source map. Skipping the map is the classic mistake: line numbers shift and the consumer debugs their own code for an hour. If a plugin is genuinely Vite-only I use addVitePlugin with dev / build gates and enforce: 'pre' when I need to see source before the SFC compiler, and under Vite's Environment API I scope it with applyToEnvironment rather than the deprecated client and server options. And I keep an eye on optimizeDeps, because a CJS dependency discovered late is what causes the 'new dependencies optimized, reloading' loop consumers report as 'your module broke HMR'."