Skip to content

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:

ts
// 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:

ts
// 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:

ts
// 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:

ts
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:

ts
// 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:

ts
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:

ts
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:

ts
// 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):

ts
// plugins/banner.ts
import { banner } from "./my-plugin-factory";

export default banner({ text: "© 2026 Acme" });

The config:

ts
// 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:

ts
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:

ts
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 null promptly from hooks that don't apply to the current id.
  • 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) with this.meta.command === "build".
  • Keep transform fast — it runs per module, in dev, on every change.

Released under the MIT License.