Writing a Plugin
A practical, worked walkthrough: from a one-line transform to a virtual module to a plugin that takes options. If you want the exhaustive hook reference, see the Plugin API; this page is about doing.
1. The smallest plugin
A plugin is a plain object with a name and a hook, authored with definePlugin. Here's one that appends a banner to every JS/TS module:
// plugins/banner.ts
import { definePlugin } from "vantris";
export default definePlugin({
name: "banner",
transform(code, id) {
if (!/\.(?:m?j|t)sx?$/.test(id)) return null;
return `${code}\n// built with Vantris`;
},
});Reference it by path:
// vantris.config.ts
import { defineConfig } from "vantris";
export default defineConfig({
plugins: ["./plugins/banner"],
});Save any source file and the banner appears in the served/built output. That's the whole loop.
2. A virtual module
resolveId + load together let you serve a module that doesn't exist on disk. This is how you expose build-time data to your app:
// plugins/app-info.ts
import { definePlugin } from "vantris";
const VIRTUAL_ID = "virtual:app-info";
const RESOLVED = "\0" + VIRTUAL_ID; // the \0 prefix marks it internal
export default definePlugin({
name: "app-info",
resolveId(source) {
if (source === VIRTUAL_ID) return RESOLVED;
return null;
},
load(id) {
if (id !== RESOLVED) return null;
return `export const builtAt = ${JSON.stringify(new Date().toISOString())};`;
},
});Now anywhere in your app:
import { builtAt } from "virtual:app-info";
console.log(`Built at ${builtAt}`);The \0 convention
Prefixing the resolved id with \0 (a null byte) is the Rollup/Vite convention for "this is a virtual/internal id, don't try to read it from disk". Vantris follows it. Add a matching ambient declaration so TypeScript accepts the import:
// virtual.d.ts
declare module "virtual:app-info" {
export const builtAt: string;
}3. Amending the config
The config hook returns a partial that's deep-merged into the user's config. Use it to set defaults your plugin needs — e.g. pre-bundling a dependency:
import { definePlugin } from "vantris";
export default definePlugin({
name: "needs-lodash",
config(config, env) {
return {
optimizeDeps: { include: ["lodash-es"] },
define: { __PLUGIN_MODE__: env.mode },
};
},
});env.command ("serve" | "build") and env.mode let you branch on context.
4. Using the plugin context
Pipeline hooks get a this PluginContext. Here a plugin resolves a sibling file, watches an external data file, and reports a problem:
import { definePlugin } from "vantris";
import { readFile } from "node:fs/promises";
export default definePlugin({
name: "inject-schema",
async transform(code, id) {
if (!id.endsWith("/api.ts")) return null;
// rebuild/reload when the schema changes
const schemaPath = new URL("./schema.json", `file://${id}`).pathname;
this.addWatchFile(schemaPath);
let schema: string;
try {
schema = await readFile(schemaPath, "utf8");
} catch {
this.error(`inject-schema: missing ${schemaPath}`); // throws
}
return `export const schema = ${schema};\n${code}`;
},
});Remember: use a method or function, not an arrow, so this is bound. See Plugin Context.
5. A plugin that takes options
Because Vantris references plugins by string, you can't call a factory with options directly in the config. The pattern is: export a factory, then create a small local wrapper module that calls it, and reference the wrapper.
The factory:
// node_modules/my-plugin or ./plugins/my-plugin-factory.ts
import { definePlugin } from "vantris";
import type { Plugin } from "vantris";
export interface BannerOptions {
text?: string;
}
export function banner(options: BannerOptions = {}): Plugin {
const text = options.text ?? "built with Vantris";
return definePlugin({
name: "banner",
transform(code, id) {
if (!/\.(?:m?j|t)sx?$/.test(id)) return null;
return `${code}\n// ${text}`;
},
});
}
export default banner; // default export is also fine (no options)The wrapper (references this file with options applied):
// plugins/banner.ts
import { banner } from "./my-plugin-factory";
export default banner({ text: "© 2026 Acme" });The config:
// vantris.config.ts
export default defineConfig({
plugins: ["./plugins/banner"], // the wrapper, not the factory
});This is exactly the pattern the official @vantris/react plugin documents for its options.
6. Customising HMR
The handleHotUpdate hook lets a plugin decide what a file change means. Return [] to fully handle it (no default update/reload), narrow the module list, or push a custom event the client listens for:
import { definePlugin } from "vantris";
export default definePlugin({
name: "reload-on-data",
handleHotUpdate(ctx) {
if (ctx.file.endsWith(".data.json")) {
ctx.server.send("data:changed", { file: ctx.file });
return []; // we handled it
}
// otherwise fall through to default HMR
},
});On the client:
if (import.meta.hot) {
import.meta.hot.on("data:changed", ({ file }) => {
console.log("data changed:", file);
});
}This is the seam a framework's Fast Refresh integration is built on. See the HMR API.
Checklist for a good plugin
- Give it a clear, unique
name— it shows in logs and errors. - Return
nullpromptly from hooks that don't apply to the currentid. - Use
enforce: "pre"if you must run before the default pipeline (e.g. compile a custom syntax before esbuild sees it). - Guard build-only context calls (
emitFile) withthis.meta.command === "build". - Keep
transformfast — it runs per module, in dev, on every change.