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).