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.

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.

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.

// 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.

Step-by-Step Implementation

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

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.

HMR for custom assets fails with [vite] hmr update doing nothing — or a stale module — when the dev server cannot track mutations. 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.

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

  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.

Gotchas & Edge Cases

  • String from transform. Error: ... hook 'transform' returned a string — return { code, map } or null, never a bare string.
  • Relative paths in load. Error: ENOENT: no such file or directory — resolve absolutely with await this.resolve(id, importer) before reading, or rely on Vite’s already-absolute id in load.
  • Missing peer dependency. Error: Cannot find module 'vite' — declare vite in both peerDependencies and devDependencies.
  • HMR returns []. Returning an empty array from handleHotUpdate swallows the update; return ctx.modules (or omit the hook) so Vite performs its default invalidation.