Darek Kay's picture
Darek Kay
Solving web mysteries

Migrating a Create React App project to Vite

Create React App (CRA) provides an all-in-one development toolchain for your React applications. It is great for beginners, as you don't need to care about configuring your toolset. However, I've encountered more and more limitations without "ejecting", mostly due to the project's stagnation.

Vite is the new hot thing in the frontend world. It leverages new advancements in the ecosystem to provide a faster and leaner development experience. In this post, I'll go through my motivation and experience of migrating my Create React App projects to Vite.

Table of contents:

What's the hype about?

At its core, Vite is a pre-configured, extendable wrapper for two JavaScript module bundlers:

Esbuild provides instantaneous builds using ES modules, but it's officially not yet production-ready. That's why rollup, a slower but more mature tool creates the production bundle. This approach provides a great developer experience combined with a stable, optimized production build. One drawback is a potential inconsistency between both tools. However, Vite considers removing rollup as soon as esbuild becomes stable enough.

So how fast is Vite really? Here's my experience for the default starter projects:

CRAVite
version4.0.32.2.3
number of dependencies368176
size of dependencies154 MB38 MB
rm -rf node_modules && yarn86s5s
start dev server8.3s~1s

This comparison is anything but fair. CRA provides so much out of the box: Jest, ESLint, PostCSS, Babel plugins, polyfills and much more. So here are some numbers from one of my projects that I've migrated:

CRAVite
number of dependencies32881164
size of dependencies326 MB191 MB
rm -rf node_modules && yarn4m56s1m30s
start dev server (1st run)1m14s4s
start dev server (2nd run)9.4s~1s

Please notice that the times are not representative, as I ran my tests on Windows/HDD. What matters are the relative differences, though, and Vite is a clear winner in every single metric. As promised, the dev server starts instantaneously.

Bonus: here's how I got the stats:

npm ls --parseable | wc -l
# number of dependencies (deduped)
time (rm -rf node_modules && yarn)
# execution time for reinstalling node_modules

Last but not least, Vite does not only support React, but also Preact, Vue, Svelte and Lit out of the box.

Migration

The required steps to convert an existing CRA project to Vite depend on how complex your app is. In this section I will describe all the changes I've applied to migrate my own projects.

Mandatory steps

First, let's make the application run with Vite:

  • Replace dependencies in package.json:
   "dependencies": {
-    "react-scripts": "4.0.3"
   },
+  "devDependencies": {
+    "@vitejs/plugin-react": "1.1.1",
+    "vite": "2.7.0
+  }
  • Run npm install or yarn. I recommend deleting node_modules beforehand.
  • Replace scripts in package.json (I'll cover test later):
   "scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
-    "test": "react-scripts test",
-    "eject": "react-scripts eject"
+    "start": "vite",
+    "build": "vite build"
   }
  • Rename files containing JSX from *.js to *.jsx.
  • Move public/index.html to index.html (project root folder).
  • Remove %PUBLIC_URL% from index.html:
-  <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+  <link rel="icon" href="/favicon.ico" />
-  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+  <link rel="apple-touch-icon" href="/logo192.png" />
-  <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+  <link rel="manifest" href="/manifest.json" />
  • Add entry point in index.html:
   <noscript>You need to enable JavaScript to run this app.</noscript>
   <div id="root"></div>
+  <script type="module" src="/src/index.jsx"></script>
  • Add a vite.config.js file:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

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

If your project is small enough, chances are you're done! Read on for further steps that might be required.

Environmental variables

CRA doesn't use any custom configuration file. Instead, most settings are available using environmental variables, e.g. via an .env file or within npm scripts using cross-env. In this section I'll show how to migrate those settings into Vite. If you're not using any env variables in your project, you can skip this section.

Let's assume this is your .env file:

PORT=8001
PUBLIC_URL=/awesome-project/
REACT_APP_NAME=My App
REACT_APP_VERSION=$npm_package_version

First, some of those settings belong into vite.config.js:

-  PORT=8001
-  PUBLIC_URL=/awesome-project/
export default defineConfig({
  base: "/awesome-project/",
  server: {
    port: 8001,
  }, ...
]);

Next, let's see how to handle custom application variables (starting with REACT_APP_). Here's the Vite way™:

# Define custom env variable (.env file)
-  REACT_APP_NAME=My App
+  VITE_NAME=My App

# Access custom env variable (e.g. React component)
-  process.env.REACT_APP_NAME
+  import.meta.env.VITE_NAME

This will work perfectly fine within the application, but import.meta will cause problems in Jest. With Babel, you could use babel-preset-vite to fix it. There's also vite-plugin-env-compatible, but I haven't tried it out.

Another alternative is to use dotenv manually to load your custom env variables and pass them down to your application using Vite's define:

import dotenv from "dotenv";
dotenv.config();

export default defineConfig({
  define: {
    "process.env.VITE_NAME": `"${process.env.VITE_NAME}"`
  },
It's important to wrap "define" values in quotes.

This method does not support mode-specific .env files like .env.development, so you'll have to set this up yourself if required.

One special case is the process.env.NODE_ENV variable, often used to differentiate between development and production builds. Here's one way to access the mode:

export default ({ mode }) => {
  return defineConfig({
    define: {
      "process.env.NODE_ENV": `"${mode}"`,
    },
  });
};

Finally, CRA supports expanding variables, which Vite does not (at least not from process.env). If you are using variables like $npm_package_version in your .env file, you need to inject them manually using one of the methods above.

Absolute imports

Your project might depend on absolute imports:

import Button from "common/button"; // <root>/src/common/button.jsx

CRA uses Webpack aliases to resolve such imports. Vite supports a similar concept:

export default defineConfig({
  resolve: {
    alias: [{
      find: "common", replacement: resolve(__dirname, "src/common")
    }]
  },
  ...

For TypeScript projects, check out vite-tsconfig-paths instead. This plugin will automatically pick up your paths from your tsconfig.json file.

Optional steps

Some additional steps may be required, depending on your project setup:

  • If you are using a CSS pre-processor, install the corresponding dependency, e.g. yarn add -D sass for SASS.
  • The default output folder is different. Either change the Vite setting (build.outDir) or adjust your CI/CD pipeline:
    • CRA uses build folder by default.
    • Vite uses dist folder by default.
  • Install and configure optional plugins in vite.config.js, e.g. vite-plugin-legacy to support older browsers or vite-plugin-svgr to import SVG files as React components:
+  import svgr from "vite-plugin-svgr";

-  plugins: [react()]
+  plugins: [react(), svgr()]
  • For TypeScript projects, create an src/vite-env.d.ts file with the following content:
/// <reference types="vite/client" />
  • Alternatively, you can add vite/client to your tsconfig.json types. The main drawback is that you will have to list all your types explicitly if you're currently relying on typeRoots.

Linter and test runner

Making ESLint and Jest work without react-scripts is more tricky, because CRA hides the whole setup. If you want to keep your current configuration, I recommend using the eject script as the first step of the migration. This command will create all the necessary configuration files and scripts required to run ESLint and Jest. You can then manually delete everything you don't need.

I've already extracted all my development tooling into a separate package, so the transition was rather straightforward. But that's a story for another blog post.

Storybook

In one of my projects, I am using Storybook as my design system tool. Storybook uses Webpack by default and integrates nicely with CRA via a plugin. After migrating the main application to Vite, the Storybook setup was broken. There is an experimental Vite builder, but I couldn't make it work. For now, I'll keep using Webpack for Storybook while the application itself runs on Vite. This required some additional Webpack configuration (loaders, alias resolvers).

To keep Webpack dependencies away from my Vite project, I've moved Storybook into a separate module.

Conclusion

I've migrated five Create React App projects to Vite, e.g. dashboard, static-marks and board-games (check out those links to see the exact changes I've made). My experience was mostly pleasant. At first, I failed to migrate my biggest project. With Vite 2.7, most of the previous issues disappeared, though.

I think the frontend tools we've been using are being slowly overtaken by the next generation tooling. Esbuild is a great example of how fast your local development setup can run. Vite takes away the complexity of configuration and lets you create applications in seconds.

As of December 2021, Vite is still rough around the edges. It's possible you will encounter many issues trying to migrate your project from another stack. There's also no out-of-the-box ESLint or Jest setup. However, migrating away from Create React App was definitely worth it.

As always, review the trade-offs and pick the right tool for the job.


Related posts

Migrating a Create React App project to Vite