Switch to Vite

24. September, 2023 7 min read Study

The starter that stopped moving

"Create React App" got a lot of people, me included, past the part of React that used to be genuinely miserable: deciding what a build looks like. It answered the question so you didn't have to. It has also barely moved in a year, and that's now the problem.

Every few months I run npm audit on an older project and get a list of transitive vulnerabilities I can’t fix, because they live under react-scripts and react-scripts isn’t being updated. Waiting for someone else to bump a dependency isn’t a plan.

Is it dead?

Not dead. Dying, quietly, in the way open source projects usually do. The TypeScript 5 upgrade sat open for over a year. The React team’s own documentation pull request moved the recommendation towards frameworks, and the mentions of CRA came out of the official docs entirely:

If you’re still not convinced, or your app has unusual constraints not served well by these frameworks and you’d like to roll your own custom setup, we can’t stop you—go for it! Grab react and react-dom from npm, set up your custom build process with a bundler like Vite or Parcel, and add other tools as you need them for routing, static generation or server-side rendering, and more.

React Website

For small projects with low complexity I still reach for Parcel, because zero config really does mean zero config there. This walkthrough uses Vite instead, because once a project has path aliases, SVG imports, a proxy and three environments, I want the config file to exist.

Start with a Create React App

So there’s something to migrate, generate a standard CRA TypeScript project:

npx create-react-app my-app --template typescript
cd my-app
npm run start

Now run npx npm-check-updates on that brand new project and look at how much of it is already out of date. That list is the whole argument for this article.

Swap the packages

Out with react-scripts, in with Vite and the two plugins I always end up wanting:

npm uninstall react-scripts
npm install vite @vitejs/plugin-react --save-dev
npm install vite-tsconfig-paths vite-plugin-svgr --save-dev

All of these belong in devDependencies. Nothing here ships to the browser, it only builds the things that do.

There are plenty more plugins available, but these two replace behaviour CRA gave you for free:

  • vite-tsconfig-paths makes Vite honour the baseUrl and paths you already have in tsconfig.json, so import Button from 'components/button' keeps resolving instead of turning into ../../components/button.
  • vite-plugin-svgr lets you import an SVG as a React component rather than a URL.

One thing that will bite you on the SVG plugin. Version 4 landed a few days ago and changed the import syntax. The CRA-style named export is gone:

// CRA, and vite-plugin-svgr v3
import { ReactComponent as Logo } from './logo.svg';

// vite-plugin-svgr v4
import Logo from './logo.svg?react';

If you’re migrating an app with a hundred icon imports, that’s a find-and-replace across the codebase, and the error message you get otherwise (“does not provide an export named ‘ReactComponent’”) does not point at the plugin version at all. Pin the major version deliberately rather than discovering this later.

Write the config

Create a config file with touch vite.config.ts:

import { defineConfig } from 'vite';

import react from '@vitejs/plugin-react';
import viteTsconfigPaths from 'vite-tsconfig-paths';
import svgrPlugin from 'vite-plugin-svgr';

// see more at https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), viteTsconfigPaths(), svgrPlugin()],
  server: {
    port: 3000,
    open: true,
  },
});

The server block is optional and I add it every time. Vite serves on 5173 by default, CRA used 3000, and if you have a backend with CORS rules or an OAuth callback registered against port 3000 you will notice immediately.

Move index.html

This is the part that feels wrong the first time. CRA keeps index.html in public/ and treats it as a template. Vite treats it as the entry point of the application, so it goes in the project root:

mv public/index.html index.html

Vite still serves everything else in public/ at the root of the site, so %PUBLIC_URL% has no job any more. Strip it out:

<!-- before -->
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />

<!-- after -->
<link rel="icon" href="/favicon.ico" />

Then point the page at your entry module. CRA injected this at build time, Vite expects it to be in the HTML:

<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- add this line below the root declaration -->
<script type="module" src="/src/index.tsx"></script>

If the page loads blank with no errors in the console, this line is what’s missing. I’ve made that mistake more than once.

Sort out TypeScript

Open tsconfig.json and update the compiler target, because there’s no point shipping ES5 through a tool built around native modules:

"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],

CRA leaves behind src/react-app-env.d.ts, which references react-scripts types you just uninstalled. Delete it and write the Vite equivalent in its place:

rm src/react-app-env.d.ts
touch src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

The first line gives you types for import.meta.env, the second for the ?react SVG imports. Put the file in src/, not the project root, or it won’t be picked up by the default include.

Environment variables

The quiet one. CRA exposed anything prefixed with REACT_APP_ on process.env. Vite exposes anything prefixed with VITE_ on import.meta.env, and process doesn’t exist in the browser at all:

// CRA
const url = process.env.REACT_APP_API_URL;

// Vite
const url = import.meta.env.VITE_API_URL;

Both the prefix and the object change, so every variable needs renaming in .env and in the code. There’s no warning for this. You get undefined at runtime, usually in the one code path that isn’t covered by a test.

While you’re in there, import.meta.env.MODE, .DEV and .PROD replace the NODE_ENV checks.

Scripts

Finally, the entries in package.json:

"scripts": {
  "start": "vite",
  "dev": "vite",
  "build": "tsc && vite build",
  "serve": "vite preview"
},

I keep start as an alias for dev so that muscle memory and any CI job referring to npm start both keep working. tsc && vite build runs the type check before the bundle, because Vite strips types with esbuild and will happily build code that doesn’t type-check.

Then npm install && npm run start and you should be looking at the same app, starting considerably faster.

Note there’s no test script here. react-scripts test was Jest with a pile of configuration behind it, and that configuration left with the package. That’s the subject of the next article.

Starting fresh instead

If you’re not migrating anything and just want a new project, skip all of the above:

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev

Note it’s npm run dev and not npm run start, which catches me out roughly every second time. Pick react-ts rather than react unless you have a reason to avoid TypeScript. The full list of templates lives in the create-vite repository, and the React one is a genuinely small amount of code, which after years of squinting at CRA’s ejected webpack config is a relief.

What’s still missing

The app runs. Linting, formatting and tests do not, because all three came bundled with react-scripts and left with it. That’s what I’ll set up in the next article, along with the leftovers in package.json that nobody needs any more.

‘Till next time!