Advanced Vite Plugin Configuration

Vite’s plugin architecture extends the Rollup interface with dev-server-specific hooks, environment-aware execution contexts, and a tightly coupled esbuild pre-bundler. For build engineers and framework maintainers, mastering this interface is the precondition for extending the Vite Configuration & Ecosystem without introducing pipeline bottlenecks or hydration mismatches — read that overview first if you have not pinned your Vite and Rollup versions. The guides here isolate plugin lifecycle management, hook orchestration, SSR branching, and asset pipeline construction, with exact configuration patterns, CLI diagnostics, and measurable performance baselines.

The reason plugin configuration is hard is that Vite is not one build system but two systems wearing the same config file. During development there is no bundle: Vite serves ES modules over native import, transforming each file lazily the first time the browser requests it, and it pre-bundles third-party dependencies with esbuild so CommonJS packages and deep import chains do not flood the browser with hundreds of requests. During production there is a real bundle: Rollup walks the module graph, tree-shakes, splits chunks, and writes hashed assets to disk. A plugin author writes one object and it is consumed by both engines, which run overlapping but not identical hook sets. Almost every “works in dev, breaks in build” report — and its mirror image — traces back to a hook that only one of the two engines calls, or to a transform whose output the other engine re-processes.

Where a plugin sits in this pipeline determines what it can see. A transform that runs before Vite’s import-analysis pass sees bare specifiers and raw JSX; the same transform run after that pass sees rewritten import paths and injected HMR boilerplate. Getting the position wrong does not usually throw — it silently produces the wrong output, which is worse, because it surfaces as a hydration mismatch, a duplicated dependency, or a cache that never invalidates rather than as a stack trace. The cost of a misplaced hook is paid continuously: every cold start re-parses modules a second engine already parsed, every HMR update ships a larger payload, and every CI build spends wall-clock time on work that a correctly-lane’d plugin would have skipped. Treating the pipeline order as load-bearing configuration, not an implementation detail, is what separates a plugin that scales to a thousand-module app from one that only works on the example.

A Vite plugin is a plain object (usually returned from a factory function) whose keys are hooks. At dev time Vite runs the universal hooks plus its own dev-only hooks (configureServer, handleHotUpdate, transformIndexHtml); at build time it hands the same plugin objects to Rollup, which runs the build hooks. The two properties that decide when a hook fires relative to everyone else — enforce and apply — are the source of most plugin bugs, so the diagram below fixes the canonical order in your head before any code.

The Vite plugin hook pipeline with enforce lanes A left-to-right sequence from config through configResolved, resolveId, load, transform, and renderChunk, split into enforce pre, normal, and post lanes. config → configResolved → resolveId → load → transform → renderChunk enforce: 'pre' (default) enforce: 'post' config mutate config config- Resolved resolveId id → path load read source transform rewrite code renderChunk final output Within each hook, 'pre' plugins run first, then unmarked, then 'post' — Vite's core plugins sit in the unmarked lane.
Figure: the Vite plugin hook pipeline. `enforce` selects a lane; within a hook the lanes run pre → normal → post.

Prerequisites

These guides assume Vite 5.x or 6.x on Node 18.19+ / 20.x, with Rollup 4.x as the build backend. Install the toolchain and the two diagnostic plugins referenced throughout:

# Vite 5.x / 6.x, Node 20+
npm create vite@latest plugin-lab -- --template vanilla-ts
cd plugin-lab
npm install
npm install -D vite-plugin-inspect

Type definitions ship with vite; import Plugin and PluginOption from it directly. Declare vite as a peerDependency (not a hard dependency) in any plugin you publish so consumers control the installed version — a hard dependency ships a second copy of Vite into the consumer’s tree and breaks singleton assumptions in the dev server.

The singleton point deserves elaboration because the failure mode is opaque. Vite’s dev server holds module-level state: the module graph, the transform cache, the WebSocket server for HMR. If a plugin depends on its own copy of vite, the ViteDevServer instance the plugin’s configureServer hook receives and the one the CLI booted are different objects backed by different module registries. Calls like server.moduleGraph.invalidateModule then mutate a graph nobody is serving from, so HMR silently no-ops and you chase a “changes not reflecting” bug that has nothing to do with your file watcher. Pinning vite to a peerDependency range such as ^5.0.0 || ^6.0.0 and adding it to devDependencies for local type-checking is the only configuration that guarantees the consumer’s single instance is the one your hooks touch. Run npm ls vite in the consumer project after install; a healthy tree shows exactly one resolved version, and any deduplication warning is a defect to fix before publishing.

The vite-plugin-inspect install is not optional tooling for this material. Nearly every diagnosis below assumes you can open /__inspect/ and read the exact sequence of transforms applied to a given module, with the input and output of each hook. Without it you are reasoning about hook order from the outside; with it you are reading the ground truth the dev server recorded.

Plugin toolchain and the peerDependency rule The baseline is Vite 5 or 6 with Rollup 4 as the build backend on Node 18.19 or newer; a published plugin must declare vite as a peerDependency rather than a hard dependency. Vite 5.x / 6.xplugin host Rollup 4.xbuild backend Node 18.19+ / 20runtime vite = peerDepnever a hard dep Toolchain baseline
Figure: the three host versions plus the one packaging rule that keeps a single Vite instance in the consumer tree.

Core Mechanics: enforce, apply, and Hook Order

Vite plugins operate across two execution phases: the development server (serve) and the production bundler (build). The apply and enforce properties dictate when and where a plugin executes relative to Vite’s core transformation chain. Misaligned precedence causes redundant transpilation, inflating cold-start times by 15–30% in large dependency graphs.

enforce sorts plugins into three lanes — 'pre', undefined (normal), and 'post' — and Vite’s own plugins (alias resolution, the import-analysis transform, the esbuild loader) sit in the normal lane. A transform that must see raw source before Vite touches it belongs in 'pre'; a minifier or instrumentation pass that must see the final code belongs in 'post'. The precise resolution rules, plus how to log the real order, live in Debugging Vite Plugin Hook Order with enforce and apply. apply is the orthogonal control: it gates the whole plugin to one phase, while enforce orders it within a phase.

How enforce ordering actually resolves

Internally Vite does not sort your plugins array once and run it. It builds three concatenated lists — the 'pre' plugins in array order, then the normal plugins, then Vite’s own core plugins, then the 'post' plugins — and every hook is invoked by walking that flattened list. There are two consequences that trip people up. First, core Vite plugins live at the end of the normal lane, not interleaved with your normal-lane plugins, so an unmarked plugin of yours always runs before alias resolution and import analysis; if you needed to run after them you needed 'post', and the fact that your unmarked plugin “seemed to work” was luck about what the core plugins happened to emit. Second, ordering is deterministic within a lane but only by array position, so a refactor that reorders the plugins array — or a framework preset that spreads an array of sub-plugins into yours — can move a hook relative to its neighbor without any type error. This is why the recommendation is to be explicit: give a plugin enforce: 'pre' when it genuinely must precede core, rather than relying on it appearing early in the array.

Hooks that return a value participate in a first-non-null-wins protocol for the resolution hooks and a chained protocol for the transform hooks. resolveId and load stop at the first plugin that returns a non-null result — order decides the winner, and a 'pre' plugin that resolves an id denies every later plugin the chance to see it. transform is different: it is a pipeline, so every plugin whose transform returns non-null feeds its output as the input to the next plugin’s transform. That distinction is the mechanism behind most “my transform sees already-rewritten code” reports: the code is not raw because an earlier plugin in the chain already rewrote it, and the fix is to move up a lane, not to change the regex.

apply gates the phase, enforce orders within it apply serve runs a plugin only in the dev server with Vite's own dev hooks; apply build runs it only in the Rollup build; enforce pre, normal or post orders the plugin within whichever phase apply admitted it to. apply: 'serve' dev server only configureServer, handleHotUpdate, transformIndexHtml within phase: pre → normal → post apply: 'build' Rollup build only buildStart, renderChunk, generateBundle within phase: pre → normal → post
Figure: apply is a phase switch; enforce is the ordering within whichever phase the plugin runs.

Implementation Workflow

  1. Initialize a factory function returning a type-safe Plugin (or PluginOption[]) value.
  2. Define apply to restrict execution to 'serve', 'build', or a conditional predicate.
  3. Set enforce ('pre', 'post', or omit it) to position hooks relative to Vite’s native transforms.
  4. Register the plugin in vite.config.ts with explicit array ordering for deterministic resolution within a lane.
// vite-plugin-archetype.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';

interface ArchetypeOptions {
  enforce?: 'pre' | 'post';
}

export function archetypePlugin(options: ArchetypeOptions = {}): Plugin {
  return {
    name: 'vite-plugin-archetype',
    // Restrict to production builds; a predicate also receives { command, mode }
    apply: (config, { command }) => command === 'build',
    enforce: options.enforce,
    configResolved(config) {
      console.log(`[archetype] resolved for ${config.command} | Node ${process.version}`);
    },
    transform(code, id) {
      if (!id.endsWith('.ts')) return null;
      // Returning null skips this module; otherwise return { code, map }
      return { code, map: null };
    },
  };
}

Configuration Patterns

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { archetypePlugin } from './vite-plugin-archetype';

export default defineConfig({
  plugins: [
    // Runs before Vite's core JS/TS transforms because of enforce: 'pre'
    archetypePlugin({ enforce: 'pre' }),
    // Runs after core transforms — sees the post-esbuild output
    archetypePlugin({ enforce: 'post' }),
  ],
});

Debugging & Diagnostics

  • Run vite --debug plugin to print the hook invocation sequence and the applied plugin list.
  • Install vite-plugin-inspect and open /__inspect/ to visualize the transform chain per module in the browser.
  • Verify precedence by inspecting configResolved output; two plugins with the same enforce value resolve in array order, which is easy to break during refactors.

Read those three tools as a sequence, not alternatives. vite --debug plugin answers “which plugins are loaded and in what flattened order”, which is the first thing to confirm because a plugin conditionally excluded by apply simply will not appear — its absence, not a misfire, is often the whole bug. vite-plugin-inspect answers the next question, “what did each transform do to this specific module”, by recording the input and output of every hook per id; when a transform’s output looks pre-rewritten, the inspector shows you which earlier plugin rewrote it, turning a guessing game into a diff. The configResolved log is the cheapest confirmation that array order is what you think it is: print config.plugins.map(p => p.name) once and diff it against your mental model whenever you touch the config or bump a framework preset. Confirm a fix by re-running the same module through the inspector and checking that the offending intermediate step is gone, not by checking that the final output happens to look right.

A frequent edge case: apply accepts a predicate, and a predicate that reads mode rather than command will run in vite build --mode development, which is a real configuration teams use for staging bundles. If your plugin injects debug instrumentation, gate it on command === 'serve' or explicitly on mode, not on a loose truthiness check, or staging builds will ship the instrumentation. Confirm the gate by running vite build --mode staging and grepping dist/ for the instrumentation marker.

Performance Impact: Correct enforce alignment eliminates duplicate AST parsing. In benchmarks with 500+ modules, moving heavy regex transforms to enforce: 'post' reduces dev-server CPU overhead by ~18% and cuts HMR payload serialization by 12–15ms per update.

The mechanism behind that number is worth stating so you can predict it rather than measure it every time. A transform that runs in the normal lane executes before esbuild’s TS-to-JS pass, so if it emits code that still contains TypeScript syntax, esbuild re-parses the entire module to strip types — the parse you paid for in your transform is thrown away and paid for again. Moving a transform that only needs to see post-JS output to 'post' lets it operate on the already-stripped source, so the module is parsed once. The HMR payload effect follows from the same cause: a normal-lane transform that expands code (adds imports, inlines constants) enlarges the string that the dev server diffs and serializes on every keystroke-triggered update, and a 'post' transform that runs after Vite has already computed the HMR boundary does not participate in that per-update cost at all.

Orchestrating Virtual Modules and esbuild Interop

Vite delegates dependency pre-bundling to esbuild before handing resolved modules to Rollup. Advanced configurations intercept this pipeline without invalidating the pre-bundle cache or triggering duplicate transformations. Proper alignment with Optimizing Vite Dev Server and HMR ensures custom hooks do not degrade cold-start performance or inflate HMR payloads. The \0 prefix is the load-bearing convention here: it marks an id as internal so no other plugin, and not esbuild, attempts to resolve it against the filesystem.

Pre-bundling runs once, on the first dev start, and Vite caches the result in node_modules/.vite/deps keyed by a hash of your lockfile, the relevant vite.config fields, and the discovered dependency set. A virtual module that a real module imports participates in dependency scanning, and this is where interception gets delicate: if your resolveId returns a virtual id during esbuild’s scan phase but your load produces different content on the next run, the cache hash does not change and the browser is served stale synthetic code until you delete the cache directory. The rule that keeps this correct is that virtual-module content should be a pure function of inputs Vite already hashes; when it genuinely must vary — a build timestamp, a git SHA — you either accept that the value is fixed per dev session or you register the dependency explicitly so a change busts the cache. Reaching for Date.now() inside load, as the archetype below does for illustration, is fine for a build-time constant but is exactly the kind of impurity that produces “the value froze” confusion in development.

Why the null byte specifically? Rollup and Vite treat any id containing \0 as synthetic and skip filesystem resolution, source-map file reads, and the “does this path exist” checks that would otherwise fire. It is also a signal to other well-behaved plugins to leave the id alone, because the convention is documented in Rollup’s plugin contract. Omit it and two things break: esbuild’s dependency scanner tries to stat a file named virtual:build-info and logs a resolution failure, and any plugin doing path-based filtering may match your virtual id as if it were a real file, double-transforming it. The prefix is one character but it is the difference between an id that flows cleanly to your load hook and one that half the pipeline fights over.

Virtual module resolution with the null-byte prefix A bare import specifier is matched in resolveId and returned as a null-byte-prefixed id; the load hook then generates synthetic source for that id while the null byte keeps every other plugin and esbuild from touching it. import 'virtual:x'bare specifier resolveId→ \0virtual:x loadsynthetic source The \0 prefix says "internal id" — nobody else resolves it against disk Drop the prefix and esbuild tries to read a file that does not exist.
Figure: the null byte is what makes a virtual id survive the pipeline untouched until load fills it.

Implementation Workflow

  1. Identify target import specifiers in the resolveId hook.
  2. Return \0-prefixed virtual module ids so other plugins (and esbuild) leave them alone.
  3. Implement the load hook to generate synthetic module content on demand.
  4. Apply transform only to the virtual ids, leaving real modules to Vite’s core.
// vite-plugin-virtual-interceptor.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';

const VIRTUAL_ID = 'virtual:build-info';
const RESOLVED_ID = '\0' + VIRTUAL_ID; // \0 marks an internal id Rollup won't touch

export function virtualInterceptor(): Plugin {
  return {
    name: 'virtual-interceptor',
    resolveId(id) {
      if (id === VIRTUAL_ID) return RESOLVED_ID;
      return null;
    },
    load(id) {
      if (id === RESOLVED_ID) {
        return `export const BUILD_ID = ${JSON.stringify(String(Date.now()))};`;
      }
      return null;
    },
  };
}

Configuration Patterns

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { virtualInterceptor } from './vite-plugin-virtual-interceptor';

export default defineConfig({
  optimizeDeps: {
    // Keep esbuild from scanning a package you resolve yourself
    exclude: ['my-custom-pkg'],
    include: ['lodash-es'],
  },
  plugins: [virtualInterceptor()],
});

Debugging & Diagnostics

  • Watch node_modules/.vite/deps for unexpected cache invalidation; deleted .js/.json files signal hook misalignment.
  • Run vite --debug optimize to confirm esbuild exclusion boundaries and dependency resolution paths.
  • Inspect the browser Network tab for duplicate requests to the same virtual id, which usually means a missing \0 prefix in resolveId.

Each of those symptoms has a distinct root cause worth spelling out. Unexpected invalidation in .vite/deps means the pre-bundle hash changed, which is almost always because a dependency your virtual module pulls in was newly discovered or a optimizeDeps.include/exclude entry moved; the fix is to make the include set explicit rather than relying on scan-time discovery, and you confirm it by starting the server twice and checking that the second start logs a cache hit rather than “optimized dependencies changed, reloading”. A vite --debug optimize run that shows esbuild reaching into a package you meant to own yourself means your exclude entry does not match the resolved specifier — bare name versus subpath — and the confirmation is that the package disappears from the “Pre-bundling dependencies” list. Duplicate network requests to a virtual id are the null-byte symptom: without the prefix the id is treated as a URL, requested over HTTP, missed in the module graph, and requested again; adding \0 in resolveId collapses it to a single graph node, which you verify by watching the request count drop to one.

A second worked example makes the optimizeDeps boundary concrete. Suppose a plugin generates a virtual barrel that re-exports icons from a large CommonJS package. If esbuild pre-bundles that package, it flattens the CommonJS into one ESM chunk and your virtual barrel imports the whole thing; if you exclude it, the package stays as native modules and your barrel can import a single icon.

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // Force the big icon set to stay unbundled so a virtual barrel can
    // import one entry point instead of pulling the flattened CJS chunk.
    exclude: ['@acme/icon-set'],
    // Pre-bundle the ESM utility eagerly so its first request is warm.
    include: ['@acme/utils'],
    // esbuild options apply to the pre-bundle pass only, not the app code.
    esbuildOptions: { target: 'es2020' },
  },
});

Performance Impact: Excluding non-standard modules from optimizeDeps stops esbuild from parsing unnecessary CommonJS wrappers, trimming dev boot time by 40–60ms per excluded package and eliminating ~200KB of redundant HMR WebSocket payloads during rapid iteration.

The trade-off runs both ways, so exclusion is not a free optimization. A package you exclude is served as native ES modules, and if it has a deep internal import graph the browser now makes one request per internal file on first load, which can be slower than one pre-bundled chunk for a package with hundreds of modules. The heuristic is: exclude packages you resolve or rewrite yourself, or whose flattening defeats your tree-shaking; pre-bundle everything else. Measure the boundary with the Network panel’s request count on a cold, cache-cleared start rather than assuming.

Context-Aware Plugin Logic for SSR and SSG

Server-side rendering and static generation require plugins to conditionally alter resolution, externalization, and execution context. The options.ssr flag passed to transform/load and the resolve.conditions array give precise control over Node vs. browser entry points. Aligning plugin behavior with Vite SSR and SSG Integration prevents hydration mismatches and produces a correct server dependency graph. The same source module is transformed twice — once for each target — so the ssr flag is how one plugin emits two different outputs from one input.

The double-transform is the mechanism most people miss, and it is the direct cause of hydration mismatches. When a page is server-rendered and hydrated, the same component module passes through your transform twice: once with options.ssr === true to produce the code Node executes to generate HTML, and once with options.ssr === false to produce the code the browser executes to attach event handlers. If those two outputs diverge in anything that affects rendered markup — a different default value, a branch that includes a timestamp on the server but not the client, a polyfill that changes formatting — React or Vue sees server HTML that does not match what the client would have produced and throws a hydration warning, then discards the server markup and re-renders. The discipline is that the ssr branch may differ in how it obtains a value (Node built-in versus browser shim) but must not differ in what value ends up in the DOM. Confirm parity by rendering a component both ways and diffing the serialized output, not by eyeballing the two code paths.

There is a subtlety about when the flag is reliable. The ssr argument is passed to transform, load, and resolveId, but configResolved and config run once and cannot know which target is being built — Vite may run a client build and an SSR build as two separate passes over the same config. A plugin that decides behavior in configResolved based on config.build.ssr is reading the flag for the current pass, which is correct for build but meaningless in the dev server where both graphs coexist. Read the target per-hook from the ssr argument for anything that must be correct in development; reserve config.build.ssr for build-only decisions like output directory.

One source, two transform outputs by the ssr flag When the ssr flag is true the transform emits a Node-native runtime; when false it emits a lightweight browser stub, so the same source module produces the correct code for each target. runtime.tsone source module transform(..., {ssr})branch on flag ssr: node:utilserver bundle client: browser stubno Node built-ins
Figure: the ssr flag forks one transform into a server output and a browser output.

Implementation Workflow

  1. Read the ssr flag from the third argument of load, transform, and resolveId.
  2. Conditionally apply external/noExternal rules based on the execution target.
  3. Set resolve.conditions ('node', 'browser', 'module', 'default') for the right entry points.
  4. Branch build.rollupOptions.output for dual-target builds when needed.
// vite-plugin-ssr-branching.ts — Vite 5.x / 6.x
import type { Plugin } from 'vite';

export function ssrBranchingPlugin(): Plugin {
  return {
    name: 'ssr-branching',
    transform(code, id, options) {
      if (!id.endsWith('runtime.ts')) return null;
      if (options?.ssr) {
        // Node-native runtime, server execution only
        return { code: `import { promisify } from 'node:util';\n${code}`, map: null };
      }
      // Lightweight browser stub so the import never fails client-side
      return { code: `const promisify = (fn) => fn; /* browser stub */\n${code}`, map: null };
    },
  };
}

Configuration Patterns

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { ssrBranchingPlugin } from './vite-plugin-ssr-branching';

export default defineConfig({
  resolve: { conditions: ['module', 'browser', 'default'] },
  ssr: { noExternal: ['my-internal-ssr-lib'] },
  build: { ssr: true, rollupOptions: { external: ['fs', 'path', 'crypto'] } },
  plugins: [ssrBranchingPlugin()],
});

Debugging & Diagnostics

  • Run vite build --ssr and inspect the dependency tree via vite --debug resolve.
  • Compare hydration warnings in the browser console against the SSR HTML output.
  • Check ssr.noExternal for incorrectly bundled Node built-ins; these surface as ERR_REQUIRE_ESM or process is not defined at runtime.

Externalization is the SSR decision that most often goes wrong, so it is worth separating the two knobs. By default Vite externalizes every dependency in the SSR build — it leaves import 'foo' in the output and lets Node resolve foo from node_modules at runtime, which is fast and correct for packages that ship valid Node-resolvable entry points. ssr.noExternal forces a package to be bundled into the server output instead, which you need when the package ships only browser-oriented or ESM-only code that Node’s require chain cannot resolve, or when it must be transformed by your plugins. ssr.external is the explicit opposite for packages the auto-detection bundles but shouldn’t. The ERR_REQUIRE_ESM symptom means an ESM-only package was externalized into a CommonJS server bundle; the fix is to add it to noExternal so it is bundled and transformed rather than required raw, and you confirm the fix by running the server entry under Node and watching the error disappear. process is not defined in the browser means a Node-targeted branch leaked into the client bundle, which points back to an ssr flag you read incorrectly or a resolve.conditions order that put 'node' ahead of 'browser'.

The resolve.conditions order is not cosmetic. Node’s export-conditions algorithm takes the first matching condition in the array, so ['node', 'browser', 'default'] and ['browser', 'node', 'default'] resolve a dual-published package to different files. For the client build you want 'browser' to win; for the SSR build you want 'node'. Vite manages sensible defaults, but a plugin that overrides resolve.conditions globally, as the configuration below does, applies that order to both graphs — verify with vite --debug resolve that each target lands on the file you expect before shipping.

Asset Transformation and Post-Processing Pipelines

Beyond JS modules, plugins frequently handle images, fonts, and custom formats. Robust asset pipelines lean on assetsInclude, regex-based id filtering, and this.emitFile for deterministic chunk generation. For a full walkthrough, see Writing a Custom Vite Plugin for Asset Transformation. The pattern below focuses on cache-aware processing and source-map preservation. The key discipline is where each step runs: filtering and rewriting happen in transform, but emitting derived files happens in generateBundle, after all chunks are final, so there is no rebuild loop.

The reason emission cannot live in transform is the hash algorithm. Vite’s production output uses content-hashed filenames — logo.4f3a2b.png — so that a changed asset gets a new URL and an unchanged asset keeps its URL and stays in the browser cache. That hash is computed from the asset’s final bytes, which are not known until every transform in the graph has run. If you call this.emitFile from transform, you are emitting into a graph that is still mutating, so a later transform can change a dependency, shift a hash, and either invalidate the file you just emitted or, worse, trigger the graph to be re-walked and your emitFile to run twice. generateBundle runs once, after the module graph is frozen and every chunk’s content and hash are final, which is why a manifest emitted there is deterministic: rebuild the project with no changes and you get byte-identical output, which is the property CI cache keys and CDN immutability headers depend on.

assetsInclude and createFilter do related but distinct jobs, and conflating them produces the “my transform never runs” symptom. assetsInclude tells Vite’s core to treat a matching extension as an asset — to give its import a URL and copy it to dist/assets — rather than trying to parse it as JavaScript. createFilter is your plugin’s guard inside transform, deciding which ids your hook acts on. A file can be matched by your filter but not registered in assetsInclude, in which case Vite’s core tries to JS-parse it first and throws before your transform sees it; or registered as an asset but not matched by your filter, in which case it is copied verbatim with no processing. You usually need both, aligned on the same extension.

Asset pipeline from filter to emitted manifest A registered custom extension is filtered by id, rewritten in transform with a chained source map, and after all chunks are finalized a derived manifest is emitted in generateBundle. assetsIncluderegister ext createFilterid match transformrewrite + map generateBundleemitFile Rewrite in transform; emit derived files only once chunks are final
Figure: emission waits for generateBundle so the manifest is deterministic and cannot trigger a rebuild.

Implementation Workflow

  1. Register custom extensions via assetsInclude: ['**/*.custom'].
  2. Implement transform with strict regex id filtering (@rollup/pluginutils createFilter).
  3. Generate source maps with this.getCombinedSourcemap() and return the map.
  4. Emit processed assets via this.emitFile({ type: 'asset', source, fileName }) in generateBundle.
// vite-plugin-asset-pipeline.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';
import { createFilter } from '@rollup/pluginutils';

export function assetPipelinePlugin(): Plugin {
  const filter = createFilter(['**/*.custom']);
  return {
    name: 'asset-pipeline',
    transform(code, id) {
      if (!filter(id)) return null;
      const processed = code.replace(/CUSTOM_TOKEN/g, 'REPLACED');
      // Chain upstream maps so DevTools still points at the original file
      return { code: `export default ${JSON.stringify(processed)};`, map: this.getCombinedSourcemap() };
    },
    generateBundle() {
      // Emitted after all chunks are finalized — deterministic, no rebuild loop
      this.emitFile({
        type: 'asset',
        fileName: 'asset-manifest.json',
        source: JSON.stringify({ generatedAt: Date.now() }),
      });
    },
  };
}

Configuration Patterns

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import { assetPipelinePlugin } from './vite-plugin-asset-pipeline';

export default defineConfig({
  assetsInclude: ['**/*.svg', '**/*.custom'],
  build: { sourcemap: true },
  plugins: [assetPipelinePlugin()],
});

Debugging & Diagnostics

  • Verify source-map integrity with vite build --sourcemap and the DevTools Sources panel.
  • Check dist/assets/ for orphaned or duplicated files, which indicate emitFile misconfiguration.
  • Run vite --debug transform to trace asset pipeline order, cache hit ratios, and transform latency.

Source maps are the asset detail that quietly rots if you ignore it. When your transform rewrites a file, the browser’s DevTools and every downstream tool need a map from the emitted code back to the original bytes, and if an earlier plugin already transformed the module there is an upstream map you must compose with your own or the mapping points at the wrong lines. this.getCombinedSourcemap() returns the accumulated map of every transform applied to this module so far; returning it as your map chains your rewrite onto that history. Return null and you break the chain — DevTools will show the post-transform code as if it were the source, and stack traces in errors will point at generated line numbers. This is only valid inside transform; calling it in load or generateBundle throws because there is no per-module transform context there. Confirm the chain is intact by opening the Sources panel on a production build with sourcemap: true and checking that a breakpoint in the original file lands on the original line.

The orphaned-file symptom in dist/assets/ almost always means emitFile ran in a hook that executes per-module rather than once. If you see the same derived file emitted with two different hashes, the emission is inside transform or buildStart and is firing for each entry point; move it to generateBundle and the duplication collapses to one file. Confirm by counting occurrences in the build log’s emitted-assets summary.

Performance Impact: Cache-aware transforms avoid redundant image/font work, saving 200–400ms per build in projects with 100+ custom assets. getCombinedSourcemap() chaining preserves original references, and generateBundle emission guarantees deterministic manifests without incremental-rebuild loops.

Compatibility Matrix

Plugin API stability across Vite 5 and 6 enforce, apply, the ssr transform flag and null-byte virtual ids are stable across Vite 5 and 6; the Environment API is experimental in Vite 5 and stable in Vite 6, which also drops Node 18 EOL targets. Vite 5.xEnvironment API experimental Vite 6.xEnvironment API stable; Node 18 dropped Stable across both: enforce, apply, ssr flag, \0 virtual ids, getCombinedSourcemap
Figure: for portable plugins prefer the stable-across-both surface; treat the Environment API as 6.x-first.
Plugin feature Vite 5.x Vite 6.x Rollup backend Notes
enforce / apply Yes Yes 4.x Identical semantics across both lines
options.ssr arg in transform Yes Yes 4.x Replaces older this.environment.ssr reads
Environment API (this.environment) Experimental Stable 4.x Prefer the ssr flag for portable plugins
\0 virtual module ids Yes Yes 4.x Required so Rollup skips path resolution
this.getCombinedSourcemap() Yes Yes 4.x Only valid inside transform
Node version 18.19+ / 20.x 18.19+ / 20.x / 22.x Vite 6 drops Node 18 EOL targets

In-Depth Guides