Vite (pronounced “veet”) is a newish JavaScript bundler. It comes batteries-included, requires almost no configuration to be useful, and includes plenty of configuration options. Oh—and it’s fast. Incredibly fast.
This post will walk through the process of converting an existing project to Vite. We’ll cover things like aliases, shimming webpack’s dotenv handling, and server proxying. In other words, we’re looking at how to move a project from its existing bundler to Vite. If you’re looking instead to start a fresh project, you’ll want to jump to their documentation.
Long story, short: the CLI will ask for your framework of choice—React, Preact, Svelte, Vue, Vanilla, or even lit-html—and whether you want TypeScript, then give you a fully functioning project.
Scaffold first! If you are interested in learning about integrating Vite into a legacy project, I’d still recommend scaffolding an empty project and poking around it a bit. At times, I’ll be pasting some clumps of code, but most of that comes straight from the default Vite template.
Our use case
What we’re looking at is based on my own experience migrating the webpack build of my booklist project (repo). There isn’t anything particularly special about this project, but it’s fairly big and old, and leaned hard on webpack. So, in that sense, it’s a good opportunity to see some of Vite’s more useful configuration options in action as we migrate to it.
What we won’t need
One of the most compelling reasons to reach for Vite is that it already does a lot right out of the box, incorporating many of the responsibilities from other frameworks so there are fewer dependencies and a more established baseline for configurations and conventions.
So, instead of starting by calling out what we need to get started, let’s go over all the common webpack things we don’t need because Vite gives them to us for free.
Static asset loading
We usually need to add something like this in webpack:
{
test: /\.(png|jpg|gif|svg|eot|woff|woff2|ttf)$/,
use: [
{
loader: "file-loader"
}
]
}
This takes any references to font files, images, SVG files, etc., and copies them over to your dist folder so they can be referenced from your new bundles. This comes standard in Vite.
Styles
I say “styles” as opposed to “css” intentionally here because, with webpack, you might have something like this:
{
test: /\.s?css$/,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"]
},
// later
new MiniCssExtractPlugin({ filename: "[name]-[contenthash].css" }),
…which allows the application to import CSS or SCSS files. You’ll grow tired of hearing me say this, but Vite supports this out of the box. Just be sure to install Sass itself into your project, and Vite will handle the rest.
Transpilation / TypeScript
It’s likely your code is using TypeScript, and or non-standard JavaScript features, like JSX. If that’s the case, you’ll need to transpile your code to remove those things and produce plain old JavaScript that a browser (or JavaScript parser) can understand. In webpack that would look something like this:
{
test: /\.(t|j)sx?$/,
exclude: /node_modules/,
loader: "babel-loader"
},
…with a corresponding Babel configuration to specify the appropriate plugins which, for me, looked like this:
{
"presets": ["@babel/preset-typescript"],
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
}
While I could have probably stopped using those first two plugins years ago, it doesn’t really matter since, as I’m sure you’ve guessed, Vite does this all for us. It takes your code, removes any TypeScript and JSX, and produces code supported by modern browsers.
If you’d like to support older browsers (and I’m not saying you should), then there’s a plugin for that.
node_modules
Surprisingly, webpack requires you to tell it to resolve imports from node_modules
, which we do with this:
resolve: {
modules: [path.resolve("./node_modules")]
}
As expected, Vite already does this.
Production mode
One of the common things we do in webpack is distinguish between production and development environments by manually passing a mode
property, like this:
mode: isProd ? "production" : "development",
…which we normally surmise with something like this:
const isProd = process.env.NODE_ENV == "production";
And, of course, we set that environment variable via our build process.
Vite handles this a bit differently and gives us different commands to run for development builds versus those for production, which we’ll get into shortly.
File extensions
At the risk of belaboring the point, I’ll quickly note that Vite also doesn’t require you to specify every file extension you’re using.
resolve: {
extensions: [".ts", ".tsx", ".js"],
}
Just set up the right kind of Vite project, and you’re good to go.
Rollup plugins are compatible!
This is such a key point I wanted to call it out in its own section. If you still wind up with some webpack plugins you need to replace in your Vite app when you finish this blog post, then try to find an equivalent Rollup plugin and use that. You read that correctly: Rollup plugins are already (or usually, at least) compatible with Vite. Some Rollup plugins, of course, do things that are incompatible with how Vite works—but in general, they should just work.
For more info, check out the docs.
Your first Vite project
Remember, we’re moving an existing legacy webpack project to Vite. If you’re building something new, it’s better to start a new Vite project and go from there. That said, the initial code I’m showing you is basically copied right from what Vite scaffolds from a fresh project anyway, so taking a moment to scaffold a new project might also a good idea for you to compare processes.
The HTML entry point
Yeah, you read that right. Rather than putting HTML integration behind a plugin, like webpack does, Vite is HTML first. It expects an HTML file with a script tag to your JavaScript entrypoint, and generates everything from there.
Here’s the HTML file (which Vite expects to be called index.html
) we’re starting with:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>The GOAT of web apps</title>
</head>
<body>
<div id="home"></div>
<script type="module" src="/reactStartup.tsx"></script>
</body>
</html>
Note that the <script>
tag points to /reactStartup.tsx
. Adjust that to your own entry as needed.
Let’s install a few things, like a React plugin:
npm i vite @vitejs/plugin-react @types/node
We also create the following vite.config.ts
right next to the index.html
file in the project directory.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});
Lastly, let’s add a few new npm scripts:
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
Now, let’s start Vite’s development server with npm run dev
. It’s incredibly fast, and incrementally builds whatever it needs to, based on what’s requested.
But, unfortunately, it fails. At least for right now.
We’ll get to how to set up aliases in a moment, but for now, let’s instead modify our reactStartup
file (or whatever your entry file is called) as follows:
import React from "react";
import { render } from "react-dom";
render(
<div>
<h1>Hi there</h1>
</div>,
document.getElementById("home")
);
Now we can run it our npm run dev
command and browse to localhost:3000
.
Hot module reloading (HMR)
Now that the development server is running, try modifying your source code. The output should update almost immediately via Vite’s HMR. This is one of Vite’s nicest features. It makes the development experience so much nicer when changes seem to reflect immediately rather than having to wait, or even trigger them ourselves.
The rest of this post will go over all the things I had to do to get my own app to build and run with Vite. I hope some of them are relevant for you!
Aliases
It’s not uncommon for webpack-based projects to have some config like this:
resolve: {
alias: {
jscolor: "util/jscolor.js"
},
modules: [path.resolve("./"), path.resolve("./node_modules")]
}
This sets up an alias to jscolor
at the provided path, and tells webpack to look both in the root folder (./
) and in node_modules
when resolving imports. This allows us to have imports like this:
import { thing } from "util/helpers/foo"
…anywhere in our component tree, assuming there’s a util
folder at the very top.
Vite doesn’t allow you to provide an entire folder for resolution like this, but it does allow you to specify aliases, which follow the same rules as the @rollup/plugin-alias:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
resolve: {
alias: {
jscolor: path.resolve("./util/jscolor.js"),
app: path.resolve("./app"),
css: path.resolve("./css"),
util: path.resolve("./util")
}
},
plugins: [react()]
});
We’ve added a resolve.alias
section, including entries for everything we need to alias. Our jscolor
util is set to the relevant module, and we have aliases for our top-level directories. Now we can import from app/
, css*/*
, and util/
from any component, anywhere.
Note that these aliases only apply to the root of the import, e.g. util/foo
. If you have some other util folder deeper in your tree, and you reference it with this:
import { thing } from "./helpers/util";
…then the alias above will not mess that up. This distinction is not well documented, but you can see it in the Rollup alias plugin. Vite’s alias matches that same behavior.
Environment variables
Vite, of course, supports environment variables. It reads config values out of your .env
files in development, or process.env
, and injects them into your code. Unfortunately, things work a bit differently than what you might be used to. First, it does not replace process.env.FOO
but rather import.meta.env.FOO
. Not only that, but it only replaces variables prefixed with VITE_
by default. So, import.meta.env.VITE_FOO
would actually be replaced, but not my original FOO
. This prefix can be configured, but not set to empty string.
For a legacy project, you could grep and replace all your environment variables to use import.meta.env
, then add a VITE_
prefix, update your .env
files, and update the environment variables in whatever CI/CD system you use. Or you can configure the more classic behavior of replacing process.env.ANYTHING
with values from a .env
file in development, or the real process.env
value in production.
Here’s how. Vite’s define
feature is basically what we need. This registers global variables during development, and does raw text replacement for production. We need to set things up so that we manually read our .env
file in development mode, and the process.env
object in production mode, and then add the appropriate define
entries.
Let’s build that all into a Vite plugin. First, run npm i dotenv
.
Now let’s look at the code for the plugin:
import dotenv from "dotenv";
const isProd = process.env.NODE_ENV === "production";
const envVarSource = isProd ? process.env : dotenv.config().parsed;
export const dotEnvReplacement = () => {
const replacements = Object.entries(envVarSource).reduce((obj, [key, val]) => {
obj[`process.env.${key}`] = `"${val}"`;
return obj;
}, {});
return {
name: "dotenv-replacement",
config(obj) {
obj.define = obj.define || {};
Object.assign(obj.define, replacements);
}
};
};
Vite sets process.env.NODE_ENV
for us, so all we need to do is check that to see which mode we’re in.
Now we get the actual environment variables. If we’re in production, we grab process.env
itself. If we’re in dev, we ask dotenv to grab our .env
file, parse it, and get back an object with all the values.
Our plugin is a function that returns a Vite plugin object. We inject our environment values into a new object that has process.env.
in front of the value, and then we return our actual plugin object. There is a number of hooks available to use. Here, though, we only need the config
hook, which allows us to modify the current config object. We add a define
entry if none exists, then add all our values.
But before moving forward, I want to note that the Vite’s environment variables limitations we are working around exist for a reason. The code above is how bundlers are frequently configured, but that still means any random value in process.env
is stuck into your source code if that key exists. There are potential security concerns there, so please keep that in mind.
Server proxy
What does your deployed web application look like? If all it’s doing is serving JavaScript/CSS/HTML—with literally everything happening via separate services located elsewhere—then good! You’re effectively done. What I’ve shown you should be all you need. Vite’s development server will serve your assets as needed, which pings all your services just like they did before.
But what if your web app is small enough that you have some services running right on your web server? For the project I’m converting, I have a GraphQL endpoint running on my web server. For development, I start my Express server, which previously knew how to serve the assets that webpack generated. I also start a webpack watch task to generate those assets.
But with Vite shipping its own dev server, we need to start that Express server (on a separate port than what Vite uses) and then proxy calls to /graphql
over to there:
server: {
proxy: {
"/graphql": "http://localhost:3001"
}
}
This tells Vite that any requests for /graphql
should be sent to http://localhost:3001/graphql
.
Note that we do not set the proxy to http://localhost:3001/graphql
in the config. Instead, we set it to http://localhost:3001
and rely on Vite to add the /graphql
part (as well any any query arguments) to the path.
Building libs
As a quick bonus section, let’s briefly discuss building libraries. For example, what if all you want to build is a JavaScript file, e.g. a library like Redux. There’s no associated HTML file, so you’ll first need to tell Vite what to make:
build: {
outDir: "./public",
lib: {
entry: "./src/index.ts",
formats: ["cjs"],
fileName: "my-bundle.js"
}
}
Tell Vite where to put the generated bundle, what to call it, and what formats to build. Note that I’m using CommonJS here instead of ES modules since the ES modules do not minify (as of this writing) due to concerns that it could break tree-shaking.
You’d run this build with vite build
. To start a watch and have the library rebuild on change, you’d run
vite build --watch
.
Wrapping up
Vite is an incredibly exciting tool. Not only does it take the pain, and tears out of bundling web apps, but it greatly improves the performance of doing so in the process. It ships with a blazingly fast development server that ships with hot module reloading and supports all major JavaScript frameworks. If you do web development—whether it’s for fun, it’s your job, or both!—I can’t recommend it strongly enough.
Vite is awesome!
I tried to migrate my existing typescript app to vite from webpack but I’m facing an issue with exporting and importing interfaces like this:
in file a (eg: index.ts) > import file b then export it again.
The issue is described here: https://github.com/vitejs/vite/issues/2117 with Evan You’s comment saying it’s related to esbuild.
Personally, I like to prefix my aliases in Vite to prevent potential conflicts with installed Node modules or to avoid importing them from unexpected places. Even when Vite tries to handle this somehow, using prefixes will communicate to colleagues or open source contributors that imports are being made from a ”special place”.
Lately, I followed the defaults used by the SvelteKit project, which uses the
$
prefix.What would be great to add is the out-of-the-box test management, if any!
With create-react-app, I can almost immediately churn out tests, is it also the case with Vite + React (or anything else)?
Actually there’s a Vite test library now – check it out!
https://vitest.dev/
Hi Adam,
thanks for this article. I’ve got a question though regarding the MiniCssExtractPlugin.
In my vite.config.ts I got
import MiniCssExtractPlugin from ‘mini-css-extract-plugin’
// …
export default defineConfig(({ mode }) => {
return {
plugins: [
vue(),
tsconfigPaths({ loose: true }),
new MiniCssExtractPlugin({
filename: ‘ck5-mini-styles.css’
})
],
}
// …
}
However, this leads to the following error when starting my vue 3 project:
error when starting dev server:
TypeError: Invalid value used in weak set
at WeakSet.add ()
at MiniCssExtractPlugin.apply (/app/node_modules/mini-css-extract-plugin/dist/index.js:541:18)
at filterPlugin (file:///app/node_modules/vite/dist/node/chunks/dep-5e7f419b.js:61608:22)
at Array.filter ()
at resolveConfig (file:///app/node_modules/vite/dist/node/chunks/dep-5e7f419b.js:61619:71)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async createServer (file:///app/node_modules/vite/dist/node/chunks/dep-5e7f419b.js:60910:20)
at async CAC. (file:///app/node_modules/vite/dist/node/cli.js:729:24)
So how can we actually get this MiniCssExtractPlugin running in a vite config?
Cheers!
Marcel