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.
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
- Match the file extension with a regex and
createFilter. - Claim the id in
resolveIdso other plugins do not race for it. - Read bytes in
loadwith an explicit encoding. - 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
- Dev: run
npm run dev, import./data.custom, and confirmREPLACED_VALUEappears in the served module. Filter the Network tab for the?importrequest to inspect the payload. - 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))"
- Source maps: open the DevTools Sources panel and confirm the original
.custompath resolves withvite build --sourcemap.
Gotchas & Edge Cases
- String from
transform.Error: ... hook 'transform' returned a string— return{ code, map }ornull, never a bare string. - Relative paths in
load.Error: ENOENT: no such file or directory— resolve absolutely withawait this.resolve(id, importer)before reading, or rely on Vite’s already-absolute id inload. - Missing peer dependency.
Error: Cannot find module 'vite'— declarevitein bothpeerDependenciesanddevDependencies. - HMR returns
[]. Returning an empty array fromhandleHotUpdateswallows the update; returnctx.modules(or omit the hook) so Vite performs its default invalidation.
Related
- Advanced Vite Plugin Configuration — the full hook pipeline,
enforce/apply, virtual modules, and SSR branching. - Debugging Vite Plugin Hook Order with enforce and apply — proving when your
transformactually runs relative to Vite’s core. - Migrating from Webpack 5 to Vite — porting raw-loader and file-loader rules to this plugin pattern.