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

Your notes

Open in Notes

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