Adding Vite to Your Existing Web App

Avatar of Adam Rackis
Adam Rackis on

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.

Screenshot of a terminal screen with a dark background and light text. There is an error in read that says there was an error when starting the development server.

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.

Screenshot of a terminal window with a black background and light text. Green text says the development server is running at localhost.
Screenshot of a blank white page that says hi there in black in a default serif font.

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.