Moving a Create React App project to Vite

Create React App 5 landed this week after a year of waiting, and I still moved my side project to Vite. It took an evening, mostly spent renaming things.

I have a side project that started as a create-react-app two years ago. A small React app, a few pages, a dozen components, nothing clever. And every time I came back to it after a few months the routine was the same: npm start, go make a coffee, come back, wait a bit more.

Create React App 5 came out this week with webpack 5 after a year of waiting. I tried it for an evening. It works, it's faster, and npm audit finally stopped screaming. Then I moved to Vite anyway.

Why Vite

The dev server doesn't bundle your app. It serves your files as native ES modules and the browser asks for them one by one, so starting up means pre-bundling node_modules once with esbuild and nothing else. A change in a component swaps that one module. The production build is Rollup, so you still get one optimised bundle with hashed chunks at the end.

The other reason is that there is a lot less between me and the tools. react-scripts hides webpack, Babel, Jest and ESLint behind one dependency and one eject button I never wanted to press. Vite's config is a file I can read in a minute.

The move

This was done on Vite 2.7. Let's see what it took, in the order I did it.

Swap the dependencies

bash
npm uninstall react-scripts
npm install --save-dev vite @vitejs/plugin-react

And the scripts in package.json:

json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

The config

js
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  // JSX, fast refresh and the automatic runtime, so no React import in every file
  plugins: [react()],
  build: {
    // Vite's default is dist. CRA built into build and so does my Netlify config, so I kept it
    outDir: "build",
  },
});

That's the whole file for now. We'll come back to it.

Move index.html to the root

In CRA the HTML lives in public/ and webpack injects the script tag. In Vite index.html is the entry point and it lives in the project root. Move it, drop every %PUBLIC_URL%, and add the script yourself:

html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="/favicon.ico" />
    <title>My side project</title>
  </head>
  <body>
    <div id="root"></div>
    <!-- Vite starts here, the path is relative to the project root -->
    <script type="module" src="/src/index.jsx"></script>
  </body>
</html>

Everything else in public/ stays where it is and is copied into the build as it was.

Rename the JSX files

This is the one that stopped me for ten minutes. Vite uses esbuild to transform your code, and esbuild only looks for JSX in .jsx and .tsx files. CRA let you put JSX into any .js file and most of mine were like that. The error is clear enough once you actually read it:

text
Failed to parse source for import analysis because the content contains invalid JS syntax.
If you are using JSX, make sure to name the file with the .jsx or .tsx extension.

You can teach esbuild to treat every .js file as JSX in the config, but then the pre-bundler needs the same treatment and it gets ugly. Renaming is the honest fix. A file that contains /> or </ is a decent enough guess at "has JSX in it":

bash
# rename every .js file under src that looks like it has JSX in it, keeping the git history
for f in $(grep -rl --include='*.js' -e '</' -e '/>' src); do
  git mv "$f" "${f%.js}.jsx"
done

Environment variables

CRA exposes REACT_APP_* variables on process.env. Vite exposes VITE_* on import.meta.env. There is no process in the browser anymore, which is the correct state of the world, but it means a rename:

bash
# in the code and in the .env files
grep -rl 'REACT_APP_' src .env* | xargs sed -i 's/process\.env\.REACT_APP_/import.meta.env.VITE_/g; s/REACT_APP_/VITE_/g'

process.env.NODE_ENV checks become import.meta.env.DEV or import.meta.env.PROD. I had two.

SVGs as components

import { ReactComponent as Logo } from './logo.svg' is CRA's SVGR setup, not a React thing. A plain import gives you the URL in both tools, which covered most of mine. For the two icons I render inline, vite-plugin-svgr gives the same named import back:

js
// vite.config.js
import svgr from "vite-plugin-svgr";

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

Absolute imports

CRA reads baseUrl from jsconfig.json so you can write import Button from 'components/Button'. Vite doesn't read that file. The alias goes into the config and the imports get a prefix:

js
// vite.config.js
import path from "path";

export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "src"),
    },
  },
});

Then import Button from '@/components/Button'. One more search and replace.

Things to watch for

  1. process is not defined in the browser console, from a dependency that reads process.env.NODE_ENV at runtime. The fix is define: { 'process.env': {} } in the config until the library catches up.
  2. global is not defined, same story from a package written for Node. define: { global: 'window' }.
  3. The build target. Vite builds for browsers that understand native modules, which is every current browser and not IE11. IE11 still has half a year to live, and if you need it, @vitejs/plugin-legacy adds the fallback bundle. My side project doesn't.
  4. Hashed chunks are still hashed chunks. Every deploy replaces them and a tab left open overnight will ask for files that don't exist anymore. Nothing changed there, so my old post about missing chunks still applies.

What I left behind

The tests. react-scripts test was Jest with everything preconfigured, and Jest doesn't understand import.meta.env without a Babel plugin or a mock. Vitest exists and runs on the same config as Vite, but it is a couple of weeks old at this point and I have had my share of being the first one to hit a bug. The handful of tests I have will wait a few releases.

What I got

The dev server is up before my browser tab is. Saving a component swaps it in place with the state intact, and node_modules lost the long tail of packages that only react-scripts wanted. The production build is about what it was, which is fine, Rollup and webpack 5 are both good at that part.

CRA 5 is a proper release, and if your project is big and your config runs deep, staying on it is a reasonable choice. For a small app that I pick up a few times a year, not waiting on the tooling anymore is worth an evening of renames.

Read more here: