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.
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).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.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.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).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.builder: '@nuxt/rspack-builder' — one more reason to write transforms with unplugin.One implementation, every builder, with a source map:
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 }) }
},
}))
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:
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:
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
})
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.
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.
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.
__TOOLKIT_VERSION__ unplugin above, register it with addBuildPlugin, and confirm the literal appears in both the client bundle and .output/server.code.replace(...) with no map, then set a breakpoint in a transformed .vue file and watch it land on the wrong line.builder: '@nuxt/rspack-builder' and confirm the same transform still runs.pre-enforced Vite plugin with and without prepend: true, and log the resolved plugin order in configResolved to see where it actually ended up.runtime/ directory, watch the "new dependencies optimized… reloading" message, then fix it with vite.optimizeDeps.include."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'."
Auto-imports and how they bite
How unimport builds two separate registries and rewrites your identifiers at build time, why module runtime code must import from
Rendering modes and data
Universal, SPA, hybrid and static rendering; islands and lazy hydration; the real option list for useAsyncData and useFetch; the payload, plugins and unhead.