Writing a Custom Vite Plugin for Asset Transformation

You need to import a non-JS file format — a .proto, .xml, .bin, or a proprietary schema — and have Vite turn it into a usable ES module in both dev and build. This guide builds that plugin end to end; for the broader lifecycle and enforce semantics it relies on, start from Advanced Vite Plugin Configuration. Native asset handling optimizes for images, fonts, and JSON, leaving niche formats unhandled, so the plugin contract is the only supported way to wire them into resolution, caching, and HMR.

The problem exists because Vite’s module graph is fundamentally a graph of JavaScript modules. Every node the browser eventually evaluates has to be valid ECMAScript, but the files you want to import are not — they are protobuf descriptors, GraphQL SDL, CSV fixtures, or a binary blob with a length-prefixed header. Something has to sit between the byte stream on disk and the import statement in your source and produce a module the runtime can evaluate. Without that translation step the dev server returns the raw bytes with a text/plain content type, the browser refuses to execute them as a module, and you get a hard Failed to load module script error in the console. In a production build the same file is treated as a static asset and copied verbatim into dist/, so your import data from './x.custom' binding resolves to a URL string instead of the parsed value you expected — a silent correctness bug rather than a loud one.

The plugin sits at the resolution and loading boundary, which is exactly where Vite expects extension points to live. It participates in the same pipeline that powers ?raw, ?url, CSS modules, and the framework plugins, so once your transform emits a real module the rest of the toolchain — dependency pre-bundling, tree-shaking, code-splitting, source-map stitching, and HMR — treats your custom format as a first-class citizen with no further special-casing. Getting the three hooks right is therefore not a convenience: it is the difference between a format that participates in the graph and one that Vite quietly ships as an opaque file.

Custom asset transform flow An import specifier flows through resolveId, load reads the file, transform emits an ES module, and a separate handleHotUpdate path invalidates the module on change. import './x.custom' specifier resolveId claim the id load read bytes transform code + map ES module export default handleHotUpdate invalidate module on file change
Figure: the asset transform flow — `resolveId` → `load` → `transform`, with `handleHotUpdate` invalidating on change.

Prerequisites & Reproducible Setup

# Vite 5.x / 6.x, Node 20+
npm create vite@latest asset-plugin -- --template vanilla-ts
cd asset-plugin && npm install
npm install -D @rollup/pluginutils magic-string
mkdir -p src plugins && printf 'CUSTOM_TOKEN here\n' > src/data.custom

Declare vite as a peerDependency in any plugin you publish so the consumer’s installed version wins. Import Plugin from vite directly — no extra @types package is needed.

The two runtime helpers earn their place. @rollup/pluginutils gives you createFilter, which compiles an include/exclude list of globs and picklists into a single predicate that both normalizes path separators and honors the node_modules convention — reimplementing it with a bare regex is where most plugins first go wrong, because a naive /\.custom$/ also matches query-suffixed ids like x.custom?import and virtual ids Vite generates internally. magic-string maintains a mapping between every edit you make and the original character offsets, so the source map it emits is correct by construction rather than hand-computed. Neither is strictly required, but replacing them with ad-hoc string work is the most common source of subtly wrong maps and misfired filters. Pin them as ordinary dependencies of the plugin, not peerDependencies, because their versions are an implementation detail the consumer should never have to reason about.

Plugin dependencies for asset transformation The plugin uses @rollup/pluginutils for id filtering and magic-string for source-map-safe edits, imports the Plugin type from vite directly, and declares vite as a peerDependency. @rollup/pluginutilscreateFilter magic-stringsource-map-safe edits vite = peerDependencyPlugin type from it Two helpers and one packaging rule
Figure: filtering, safe edits, and the peer-dependency rule that keeps one Vite instance in the tree.

Core Hooks: resolveId, load, and transform

Vite executes plugin hooks in a strict sequence. resolveId intercepts import specifiers, load reads the raw file content, and transform applies structural modifications. Hook order matters: set enforce: 'pre' to run before Vite’s internal optimizers, as explained in Debugging Vite Plugin Hook Order with enforce and apply.

The sequence is not arbitrary; it mirrors how Rollup resolves a module. When the browser (in dev) or Rollup (in build) encounters import x from './data.custom', Vite first runs resolveId across the ordered plugin list until one returns a non-null value, which fixes the canonical id for that module in the graph. That id becomes the cache key: if two importers reference the same resolved id, load and transform run exactly once and the result is memoized. Then load runs, again first-non-null-wins, to produce the source text for that id. Finally transform runs for every plugin whose filter matches, in order, each receiving the previous plugin’s output — unlike resolveId and load, transform is a reduce, not a race. This is why transform must never assume it sees the pristine file: another plugin with enforce: 'pre' may have already rewritten it. Your filter guard (if (!filter(id)) return null) is what keeps your transform idempotent and scoped to ids you actually own.

The enforce field slots your plugin into one of three buckets Vite maintains internally: pre plugins run before Vite’s core plugins, unstamped plugins run with core, and post plugins run after — including after Vite’s own asset and CSS handling. For asset transformation you almost always want pre, because Vite’s built-in asset plugin will otherwise claim .custom first, resolve it to a URL, and your transform will never see the file content at all. Getting this wrong produces the most confusing symptom in the whole exercise: the plugin looks correct, no error is thrown, and yet the imported value is a hashed URL string.

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite';
import customAssetPlugin from './plugins/my-transform';

export default defineConfig({
  plugins: [customAssetPlugin({ enforce: 'pre' })],
});

A common failure is returning a raw string from transform. While load may return a string, transform expects an object: Vite (via Rollup) wants { code: string, map?: SourceMapInput } or null. Returning null signals Vite to skip the current module.

The asymmetry has a reason. load is defined as producing source for a module Vite does not yet have any text for, so a bare string is the natural return — there is nothing to map back to because this is the origin of the content. transform is defined as editing existing source, so returning only the new code would throw away the positional relationship between input and output; Rollup insists on the object form precisely so it can thread your map into the chain of maps every other transform contributes. When you return null from either hook, Vite reads it as “I have no opinion, defer to the next plugin,” which is why a forgotten return null on a non-matching id silently hands control back to the pipeline instead of blocking it.

Return-type contract of load versus transform load may return a raw string of file content, but transform must return an object with code and an optional map, or null to skip the module; returning a bare string from transform is an error. load returns a raw string (file content) or null to skip transform returns { code, map? } or null a bare string is an error
Figure: the asymmetry catches everyone once — load takes a string, transform must not return one.

Step-by-Step Implementation

Four implementation steps Match the extension with createFilter, claim the id in resolveId, read the bytes in load with an explicit encoding, then emit a valid ES module with a source map in transform. 1 · createFiltermatch extension 2 · resolveIdclaim the id 3 · loadread utf8 bytes 4 · transformmodule + map
Figure: the same filter guards all three hooks; only transform emits the module.
  1. Match the file extension with a regex and createFilter.
  2. Claim the id in resolveId so other plugins do not race for it.
  3. Read bytes in load with an explicit encoding.
  4. Emit a valid ES module from transform, with a source map.
// plugins/my-transform.ts — Vite 5.x / 6.x, Rollup 4.x
import type { Plugin } from 'vite';
import { readFileSync } from 'node:fs';
import { createFilter } from '@rollup/pluginutils';
import MagicString from 'magic-string';

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

export default function customAssetPlugin(opts: TransformOptions = {}): Plugin {
  const filter = createFilter(['**/*.custom']);

  return {
    name: 'vite-plugin-custom-transform',
    enforce: opts.enforce,

    resolveId(id) {
      // Claim only ids this plugin owns; return null for everything else
      return filter(id) ? id : null;
    },

    load(id) {
      if (!filter(id)) return null;
      try {
        // Explicit utf8 avoids: TypeError: Cannot read properties of undefined (reading 'toString')
        return readFileSync(id, 'utf8');
      } catch (err) {
        this.error(`Failed to load ${id}: ${(err as Error).message}`);
      }
    },

    transform(code, id) {
      if (!filter(id)) return null;
      const s = new MagicString(code);
      s.replace(/CUSTOM_TOKEN/g, 'REPLACED_VALUE');
      const body = JSON.stringify(s.toString());
      return {
        code: `export default ${body};`,
        map: s.generateMap({ source: id, includeContent: true, hires: true }),
      };
    },
  };
}

The TypeError: Cannot read properties of undefined (reading 'toString') appears when readFileSync returns a Buffer and you call string methods without an encoding. Passing 'utf8' (above) returns a string directly; alternatively wrap with Buffer.from(raw).toString('utf8').

Two details in the implementation are load-bearing beyond the filter guard. First, resolveId returns the id unchanged rather than rewriting it to an absolute path. Vite has already normalized the specifier by the time your hook runs, so echoing it back both claims ownership and preserves the id load will receive — returning a mangled path here is the usual cause of an ENOENT two hooks later. Second, this.error(...) is used instead of throw. The plugin context’s error method attaches the current module id and, in dev, formats the failure as an overlay in the browser with the offending file highlighted; a bare throw produces a stack trace in the terminal with no module context, which is materially worse to debug when the transform runs deep inside a build.

For a format that is genuinely structured rather than a token substitution, parse it in transform and emit named exports so consumers can tree-shake individual fields. The same three hooks apply; only the body of transform changes:

// plugins/my-transform.ts (transform hook) — Vite 5.x / 6.x, Rollup 4.x
transform(code, id) {
  if (!filter(id)) return null;
  // Parse the custom "KEY=VALUE" line format into a record
  const record: Record<string, string> = {};
  for (const line of code.split('\n')) {
    const eq = line.indexOf('=');
    if (eq === -1) continue;
    record[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
  }
  // Named exports let Rollup drop unused fields during tree-shaking
  const named = Object.entries(record)
    .map(([k, v]) => `export const ${k} = ${JSON.stringify(v)};`)
    .join('\n');
  return {
    code: `${named}\nexport default ${JSON.stringify(record)};`,
    map: { mappings: '' }, // generated code has no 1:1 origin; empty map is valid
  };
}

Note the map: { mappings: '' } here rather than a magic-string map. When the output is generated from parsed data rather than edited from the original text, there is no meaningful character-level correspondence to preserve, and an empty-mappings map is the honest, spec-valid way to say so. Reserve magic-string for the case where output lines still trace back to input lines.

Source Maps & HMR Invalidation

Preserving line/column accuracy means mapping original asset positions to transformed output. magic-string (used above) or @jridgewell/gen-mapping both produce compliant maps; always attach the map to the transform return object so DevTools resolves to the original file.

The hires: true option in the earlier call is worth understanding rather than cargo-culting. Without it, magic-string emits one mapping segment per edited region, which is enough to point a whole replaced span back to its origin but collapses column precision inside that span. With hires: true, it emits a segment per character, so a breakpoint set mid-line in DevTools lands on the exact original column. The cost is a larger map — for a 200-line asset the difference is negligible, but for a multi-megabyte generated file the high-resolution map can dwarf the code, so it is a knob, not a default. includeContent: true inlines the original asset text into the map’s sourcesContent array, which matters here because the .custom file is not something the browser can fetch on its own; omit it and the Sources panel shows an empty original file even though the mapping is correct.

Maps also compose. When your pre plugin transforms the file and a later plugin transforms it again, Rollup chains the two maps so a position in the final bundle still resolves to the original .custom line. This only works if every link in the chain returns a valid map object; a single plugin that returns code without a map breaks the chain from that point back, and DevTools silently falls back to the post-transform text. If your source maps stop resolving after adding an unrelated plugin, that missing-map plugin — not yours — is usually the culprit.

HMR invalidation path for a custom asset When a custom file changes, handleHotUpdate matches the extension, invalidates each affected module in the module graph, and returns the modules so Vite re-runs load and transform on the next request. .custom file changesfs event handleHotUpdateinvalidateModule return ctx.modulesre-run load/transform Without this hook, the edit does nothing or serves a stale module
Figure: handleHotUpdate is what connects a file edit to a fresh transform.

HMR for custom assets fails with [vite] hmr update doing nothing — or a stale module — when the dev server cannot track mutations. The mechanism to understand is Vite’s module graph: each imported module carries a cached transform result keyed by its resolved id, and the file watcher fires handleHotUpdate with a HmrContext describing which file changed and which graph modules currently reference it. If you do nothing, Vite applies its default heuristic, which for an id it does not recognize as a hot-updatable module often means a full page reload or, worse, serving the cached transform because nothing told the graph the cache is stale. Invalidating the module drops that cached result so the next request re-runs load and transform against the new bytes.

Implement handleHotUpdate to invalidate the module graph and return the affected modules:

// add to the plugin object above — Vite 5.x / 6.x
handleHotUpdate(ctx) {
  if (!/\.custom$/.test(ctx.file)) return;
  // Re-run load/transform for this module on the next request
  for (const mod of ctx.modules) ctx.server.moduleGraph.invalidateModule(mod);
  return ctx.modules;
},

Production Build Compatibility & Rollup Handoff

During vite dev, Vite uses esbuild for module loading; during vite build, it hands off to Rollup, which enforces strict AST validation. Non-standard syntax in your transformed output triggers RollupError: Unexpected token. The fix is to ensure transform always emits standard ECMAScript — the export default ${JSON.stringify(...)} pattern above is parse-safe because the payload is serialized to a string literal. If you must inject executable code, validate it against Rollup’s parser locally with vite build before publishing.

The dev/build divergence is the single most expensive class of bug in custom plugins, because it fails late — usually in CI, on the build step, after dev has looked green for days. The root cause is that esbuild and Rollup are two different parsers with different tolerances. esbuild is a transpiler first; it will happily accept output that is technically invalid as long as it can emit something runnable. Rollup parses to a real AST it intends to tree-shake and mangle, so anything it cannot represent as a standard node — a stray trailing expression, a comment in a position Acorn rejects, an unquoted key that happens to be a reserved word — aborts the build. The defensive posture is to treat JSON.stringify as your escaping primitive for any data you interpolate into generated code: it produces a valid ECMAScript literal for strings, numbers, booleans, null, arrays, and plain objects, and it escapes the characters that would otherwise break out of the literal. The moment you concatenate raw asset text into the code string without serializing it, you have handed the parser an injection surface and a latent build failure.

There is also a ssr dimension. When Vite builds for SSR it may invoke your transform with a second argument { ssr: true }, and the same module can be transformed twice with different targets. If your output depends on browser-only globals, branch on that flag rather than assuming a single environment; a transform that emits document-referencing code will crash the SSR bundle at import time even though the client bundle is fine.

Dev esbuild versus build Rollup handoff In dev Vite loads modules through esbuild which is lenient, while in build it hands off to Rollup which validates the AST strictly, so transform output that works in dev can throw an unexpected-token error in the build unless it is standard ECMAScript. vite dev · esbuild lenient module loading non-standard output may pass vite build · Rollup strict AST validation RollupError: Unexpected token
Figure: dev tolerance hides output bugs that the build's strict parser will reject — always test vite build.

When you need Rollup-specific behavior, reach build.rollupOptions:

// vite.config.ts — Vite 5.x / 6.x, Rollup 4.x
import { defineConfig } from 'vite';
import customAssetPlugin from './plugins/my-transform';

export default defineConfig({
  plugins: [customAssetPlugin()],
  build: {
    sourcemap: true,
    rollupOptions: {
      treeshake: { moduleSideEffects: false },
    },
  },
});

Verification

Three verification checks: dev, build, source map Confirm the replaced value appears in the served module in dev, confirm the built output module loads cleanly, and confirm the DevTools Sources panel resolves the original custom path with source maps enabled. dev · served moduleREPLACED_VALUE build · output loadsnode import() sources · original path.custom resolves
Figure: dev output, build output, and the source map — three independent confirmations.
  1. Dev: run npm run dev, import ./data.custom, and confirm REPLACED_VALUE appears in the served module. Filter the Network tab for the ?import request to inspect the payload.
  2. Build: run npm run build, then check the output module loads cleanly:
# Vite 5.x / 6.x, Node 20+
npm run build
node --input-type=module -e "import('./dist/assets/'+'data-'+'<hash>.js').then(m => console.log(m.default))"
  1. Source maps: open the DevTools Sources panel and confirm the original .custom path resolves with vite build --sourcemap.

These three checks are deliberately independent because each exercises a different half of the pipeline. The dev check proves resolveId, load, and transform fire and compose correctly under esbuild. The build check proves the same output survives Rollup’s strict parser and its tree-shaking pass — a module that dev-loads fine can still be dropped entirely if Rollup decides it has no live exports, which is why importing the default and logging it, rather than just building, is the real assertion. The source-map check is orthogonal to both: correctness of the emitted value tells you nothing about whether positions map back, and a broken map only surfaces when someone sets a breakpoint. Run all three before publishing; skipping the build check is how the dev/build divergence above reaches your users.

Performance considerations

The transform runs on the request path in dev and on the build critical path in production, so its cost is paid on every cold load and every full build. createFilter is cheap — it short-circuits on a compiled matcher — but readFileSync inside load is synchronous and blocks the dev server’s event loop for the duration of the read. For small config-like assets that is invisible; for large binaries it is not, and you should switch to the async fs.promises.readFile so the server can interleave other requests. The more subtle cost is re-transformation: because the result is cached by resolved id, a plugin that accidentally varies its output for the same input — say by embedding Date.now() or a random nonce — defeats the cache and forces a re-transform on every request. Keep transform a pure function of (code, id) and let the cache do its job. If the parse step is genuinely expensive, memoize on the file’s content hash rather than reaching for a coarser cache that risks serving stale results after an edit.

When not to use this

Reach for a plugin only when the format has to enter the module graph as a value. If you merely need the file’s URL — an icon, a downloadable sample, a worker script you instantiate by path — Vite’s built-in ?url suffix already does that with no plugin, and writing one duplicates machinery you get for free. If you need the raw text and nothing more, ?raw covers it. If the transformation is a one-time codegen step rather than a per-import concern — generating a TypeScript client from a schema, for instance — a build script that writes real .ts files is simpler to reason about, plays nicely with your editor’s type checker, and does not couple your codebase to a plugin’s lifecycle. The plugin approach earns its complexity specifically when the mapping from file to module is dynamic, per-file, and needs to stay live under HMR; outside that envelope it is usually the heavier answer to a lighter question.

Gotchas & Edge Cases

Four custom-plugin edge cases Returning a string from transform errors; a relative path in load throws ENOENT; a missing vite peer dependency errors on import; and returning an empty array from handleHotUpdate swallows the update. string from transform hook returned a string → return { code, map } or null, never a string. relative path in load ENOENT → resolve absolutely or use Vite's already-absolute id. missing peer dependency Cannot find module 'vite' → declare it in peerDependencies + devDependencies. HMR returns [] an empty array swallows the update — return ctx.modules or omit the hook.
Figure: two hard errors on top, two silent-behaviour traps below.
  • String from transform. The symptom is Error: ... hook 'transform' returned a string on the build step. The root cause is the load/transform asymmetry covered above: you copied a working load body into transform and returned the source directly. The fix is to wrap it as { code, map } or null, never a bare string. Confirm by running vite build, which is where the check fires; dev may not surface it.
  • Relative paths in load. The symptom is Error: ENOENT: no such file or directory pointing at a path relative to the current working directory rather than the importer. The root cause is calling readFileSync on an unresolved specifier. Vite passes load an already-absolute id, so the fix is usually to read that id directly; if you are constructing your own id (for a virtual or derived file), resolve it first with await this.resolve(id, importer). Confirm by logging the id at the top of load and checking it starts from the filesystem root.
  • Missing peer dependency. The symptom is Error: Cannot find module 'vite' when the consumer installs your published plugin. The root cause is declaring vite only as a devDependency, so it exists in your repo but not in theirs. The fix is to declare vite in both peerDependencies (so the consumer’s copy is used) and devDependencies (so your own build and tests resolve it). Confirm with npm pack followed by installing the tarball into a clean project.
  • HMR returns []. The symptom is that edits to the asset do nothing in the browser and no error appears. The root cause is returning an empty array from handleHotUpdate, which Vite reads as “these zero modules need updating” and so suppresses its own default handling. The fix is to return ctx.modules after invalidating, or to omit the hook entirely and let Vite’s default invalidation run. Confirm by editing the .custom file and watching the network tab re-request the ?import URL.