Migrating to Vite

Posted 29 July 2025


Our design system React component library was using Webpack to build the distributed JS files. We've just completed a migration to use Vite, giving us developer experience improvements and smaller end application bundle sizes by supporting tree-shaking.

Why Vite?

One of the main reasons we used Webpack to build our library was for consistency between our Storybook development environment and the final library build. Component changes are often solely tested in Storybook during development, and differences between the Storybook and library builds can lead to unexpected issues when the component is used in an application.

Storybook now supports Vite as a builder, so we could have the same consistency between builds. We could also use Vitest to share configuration between the library build, Storybook and unit tests.

Vite also gives some speed improvements over Webpack and offers simpler configuration with out of the box support for ESM, TS and JSX. ESM was the big appeal, as our applications were bundling the entire component library even if they only used a few components. Using ESM and tree-shaking could solve this by discarding unused JS from the final application bundle.

Migration

Library mode & ESM

To start, we added a basic vite.config.ts file to enable Vite’s library mode. Rather than preserving the module structure, we treated each index.ts or index.js file as an entry point (see rollup docs). This allowed us to build isolated entry points for each package or folder within the library. We configured Vite to output both CommonJS (cjs) and ES module (es) formats, setting the filenames to ${filename}.${format}.js. This helped avoid issues with .mjs compatibility in some consuming applications.

// vite.config.ts
import { defineConfig } from 'vite';
import { dirname, resolve, relative, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { globSync } from 'glob';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  build: {
    outDir: resolve(__dirname, 'dist/package'),
    lib: {
      entry: {
        'our-package-name': resolve(__dirname, 'index.ts'),
        ...globSync('src/**/index.{ts,js}').reduce((acc, file) => {
          acc[
            relative('src', file.slice(0, file.length - extname(file).length))
          ] = fileURLToPath(new URL(file, import.meta.url));
          return acc;
        }, {}),
      },
      name: 'our-package-name',
      fileName: (format, fileName) => `${fileName}.${format}.js`,
      formats: ['cjs', 'es'],
    },
  },
  ...
});

Finally, we updated our package.json to set main and module fields appropriately for consumers to resolve the correct format.

{
  "name": "our-package-name",
  "main": "dist/package/our-package-name.cjs.js",
  "module": "dist/package/our-package-name.es.js"
}

React

We used the official Vite React plugin to support JSX, configuring it with jsxRuntime: 'classic' for React 17 compatibility. We also set interop: 'auto' to ensure compatibility between CommonJS and ESM when importing React. This kept backwards compatibility for many of our consumers still using React 17.

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
        interop: 'auto',
      }
    }
  },
  plugins: [
    react({
      jsxRuntime: 'classic', 
    }),
  ],
});

External dependencies

To prevent bundling external dependencies into the final output, similar to how webpack-node-externals works, we used vite-plugin-externalize-deps. However, we ran into issues with radix-ui packages resolving react/jsx-runtime imports when using older versions of React (see React issue). To work around this, we selectively included those packages in the built library using the except option.

// vite.config.ts
import { defineConfig } from 'vite';
import { externalizeDeps } from 'vite-plugin-externalize-deps';

export default defineConfig({
  plugins: [
    externalizeDeps({
      except: [/radix-ui/],
    }),
  ],
});

Styles

Our library uses SASS and CSS modules for styling components. To make sure styles were included with the components without requiring a separate import in consuming applications we used vite-plugin-lib-inject-css to add import statements to each component module.

// vite.config.ts
import { defineConfig } from 'vite';
import { libInjectCss } from 'vite-plugin-lib-inject-css';

export default defineConfig({
  plugins: [
    libInjectCss(),
  ],
});

To avoid CSS files being removed during tree-shaking we also added sideEffects to our package.json:

{
  "sideEffects": ["**/*.css"]
}

Unfortunately this leads to CSS for unused components still being included in the final application bundle, but it was a trade-off between simplicity of importing components from the library. There's some discussion here about the complexity of this issue.

We also had to rename all of our SCSS files to have the .module.scss suffix, as this is how Vite determines whether to use CSS modules:

find . -type f -name "*.scss" -exec sh -c 'mv "$0" "${0%.scss}.module.scss"' {} \;

Type definitions

We also provide type definitions for a library, and opted to use vite-plugin-dts so these would be generated during the vite build command, rather than a separate call to tsc.

// vite.config.ts
import { defineConfig } from 'vite';
import { libInjectCss } from 'vite-plugin-lib-inject-css';

export default defineConfig({
  plugins: [
    dts({
      tsconfigPath: './tsconfig.json',
    }),
  ],
});

We also made sure the types field in the library's package.json file was pointing to the entrypoint .d.ts file:

{
  "name": "our-package-name",
  "types": "dist/package/our-package-name.d.ts"
}

SVG imports and Vitest

We were previously using a Babel plugin to transform our *.svg imports into raw strings, but Vite supports this functionality by adding a ?raw suffix to the import (docs).

import icon from './icon.svg'; // before
import icon from './icon.svg?raw'; // after

Jest didn't like this import style, so we migrated our tests to use Vitest to get the benefit of sharing configuration between build and unit tests.

It was fairly straightforward to migrate, replacing jest with vi for mocks and spies. We added some basic test configuration to our vite.config.ts for enabling globals, using jsdom and inlining the radix-ui dependencies to avoid similar react/jsx-runtime resolution errors we saw during the library build.

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    globals: true,
    environment: 'jsdom',
    deps: {
      inline: [/radix-ui/],
    },
  },
});

Cleaning up

After migrating we were able to clean up a bunch of no longer required configuration files and dependencies:

  • Removing our direct Babel dependencies and configuration files
  • Removing jest set up files that mocked various window properties and globals
  • Simplified Storybook configuration, as Vite handles CSS and asset imports out of the box

Results

We were able to roll out this change across our applications as a minor release, without any breaking changes for consumers.

We found an average bundle size reduction of 25% across our applications that use the component library.

We also saw an improvement in Storybook start-up and build time. Before running the Storybook in dev would require a ~30s Webpack build to complete, where it now opens in <10s (66% reduction). Building the entire Storybook now takes ~50s, down from ~115s (~57% reduction).


Related posts

Package exports and eslint-plugin-import

Published

Resolving package.json exports with eslint-plugin-import

Upgrading React with micro-frontends

Published

How to upgrade React incrementally across multiple micro-frontends

Exclude node_modules with Webpack

Published

Avoid bundling dependencies when building a library


Thanks for reading

I'm Alex O'Callaghan and this is my personal website where I write about software development and do my best to learn in public. I currently work at Mintel as a Principal Engineer working primarily with React, TypeScript & Python.

I've been leading one of our platform teams maintaining a collection of shared libraries, services and a micro-frontend architecture.

I'm from London and you can find me on a few different social media platforms: