Most bundlers resolve imports at build time. But what if you need to rewrite import paths at runtime — say, to support custom plugins, multi-tenant module overrides, or dynamic remote loading?

In this guide, you’ll learn how to intercept and rewrite ES module imports dynamically inside a Vite-based React app, without breaking tree-shaking, dev server, or production builds.


Use Case: Dynamic Plugin Loader

Imagine you're building a React app that supports 3rd-party plugins. You want to allow paths like:

import Widget from "@plugins/widget-abc";

But you won’t know widget-abc at build time — it could change per user or tenant.

Let’s fix that.


Step 1: Alias a Fake Module Prefix

In your vite.config.ts, create an alias that points to a virtual module handler:

import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "@plugins/": "/@virtual/plugin/",
    },
  },
});

This tells Vite: any @plugins/foo should redirect to /@virtual/plugin/foo.


Step 2: Create a Vite Plugin for Virtual Module Resolution

Now, use a Vite plugin to handle those fake imports:

export default function PluginImportResolver() {
  return {
    name: "dynamic-plugin-resolver",
    resolveId(source) {
      if (source.startsWith("/@virtual/plugin/")) {
        return source;
      }
    },
    async load(id) {
      if (!id.startsWith("/@virtual/plugin/")) return;

      const pluginName = id.split("/").pop();

      // Simulate remote resolution or tenant-specific override
      const resolvedPath = `/src/plugins/${pluginName}.tsx`;

      return \`export { default } from "${resolvedPath}";\`;
    },
  };
}

Add it to vite.config.ts:

plugins: [PluginImportResolver()]

Step 3: Add a Fallback Plugin or Remote Loader (Optional)

If the plugin isn’t local, you can even fetch remote modules dynamically:

if (!(await fileExists(resolvedPath))) {
  const url = \`https://cdn.example.com/plugins/\${pluginName}.js\`;
  return \`export * from "\${url}";\`;
}

Or fallback to a default:

return \`export { default } from "/src/plugins/fallback.tsx";\`;

Step 4: Use It in Your App

Now your app can import plugins dynamically — even if the plugin is resolved per-user or per-tenant:

import Widget from "@plugins/widget-abc";

This works in dev (thanks to Vite's virtual modules), and builds correctly in production too.


Pros:

  • 🧩 Enables dynamic plugin architectures with zero runtime import hacks
  • 🏗️ Works seamlessly with Vite and React, even in production
  • 🧠 Avoids eval hacks or dynamic import() messes
  • 🌍 Can support tenant-aware or remote module resolution

⚠️ Cons:

  • 📦 No support in vanilla webpack (you’d need custom loader equivalents)
  • 🔎 Must manage your plugin directory structure tightly
  • 🐞 Poor DX if a plugin fails to resolve — consider fallback/error boundaries

Summary

If you're building a plugin system, tenant-aware override engine, or any kind of app that needs to swap modules dynamically, this Vite-based approach is gold. With custom path aliasing + virtual module handling, you get the flexibility of runtime module resolution without compromising on dev experience or bundle safety. Perfect for platforms, marketplaces, and tools with user-contributed extensions.


If this was helpful, you can support me here: Buy Me a Coffee