Home/Blog/Vite vs Create React App: Migration Guide & Performance

Vite vs Create React App: Migration Guide & Performance

By TrackMy Tech Team••Tools•12 min read
Green text scrolling in a terminal window

For years, Create React App (CRA) was the default way to start a React project. That era is over: the React team officially deprecated CRA in February 2025. It no longer receives active maintenance, and new projects are directed to a framework (such as Next.js or React Router in framework mode) or to a build tool such as Vite. If you still run a CRA app in production, you are running on an unmaintained Webpack setup, and dependency warnings will only get harder to resolve over time.

For most client-rendered single-page apps, Vite is the most direct replacement. The migration is mostly mechanical, but there are a handful of places where CRA conventions do not carry over. This guide covers why Vite is faster, how to migrate step by step, and the pitfalls worth checking before you start.

Why Vite Is Faster Than Create React App

CRA wraps Webpack. In development, Webpack bundles the whole application before it serves the first page, and on every change it rebuilds the affected parts of that bundle. As a codebase grows, both start-up and rebuild times grow with it.

Vite takes a different approach in development:

  • Source code is served as native ES modules. The browser requests modules and Vite transforms each file on demand. Pages you never open are never compiled.
  • Dependencies are pre-bundled once with a fast native-code tool and cached, so a library made of hundreds of small files does not mean hundreds of requests.
  • Hot Module Replacement works per module. When you edit a file, only that module and its boundary are replaced, so updates stay fast regardless of app size.

For production, Vite bundles the app with a conventional optimising bundler. Historically that was Rollup, with esbuild used for transforms and minification. Recent Vite versions have been moving to Rolldown, a Rust-based bundler designed to replace both. Some option names have changed along the way (for example build.rollupOptions has a Rolldown-era equivalent), so check the docs for the Vite version you install.

What this means in practice:

Create React AppVite
Dev server startBundles the whole app first; slows down as the app growsStarts almost immediately; work happens per requested module
Edits during developmentRebuilds affected chunksReplaces the changed module
Production buildWebpack (no longer maintained via CRA)Optimised bundle via Rollup/Rolldown
ConfigurationHidden; needs eject, CRACO or react-app-rewired to changePlain vite.config.js
StatusDeprecated (Feb 2025)Actively maintained

Exact speedups depend on the project, so we will not quote a universal number. The difference is usually most visible on larger apps, where CRA's start-up and rebuild times have crept up the most. Measure your own before and after.

Pre-Migration Assessment

Inventory Your CRA Customisations

If you use CRACO, react-app-rewired or an ejected config, list every customisation before you begin: path aliases, extra Webpack plugins and loaders, proxy settings, environment handling and Babel plugins. Each needs a Vite equivalent, a replacement, or a decision to drop it.

Search for CRA-Specific Patterns

A few greps reveal most of the work:

# Environment variables that need renaming
grep -rn "process.env" src/

# CommonJS in application code (Vite expects ES modules in src/)
grep -rn "require(" src/

# CRA-style SVG component imports
grep -rn "ReactComponent" src/

# Placeholders in index.html
grep -n "%PUBLIC_URL%" public/index.html

# JSX inside .js files (Vite expects .jsx/.tsx for JSX by default)
grep -rln "<[A-Za-z]" src --include="*.js"

The last one is noisy but useful. CRA happily compiles JSX inside .js files; Vite does not by default. Renaming those files to .jsx is the cleanest fix.

Check Your Dependencies

Most maintained packages work with Vite unchanged, and Vite's pre-bundling converts CommonJS dependencies to ES modules for you. Problems usually come from older packages that assume Node globals in the browser (process, Buffer, global) because Webpack 4 used to polyfill them. If a dependency fails at runtime with "process is not defined" or similar, look for a newer version or a browser-friendly alternative before reaching for polyfills.

Know Your Node Version

Current Vite releases require a recent Node.js LTS (Node 20.19+ or 22.12+ for Vite 7, for example). Check the requirement for the version you install and update local machines and CI runners first.

Step-by-Step Migration

1. Swap the Dependencies

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

# Only if you need them
npm install --save-dev vite-plugin-svgr        # SVGs as React components
npm install --save-dev vite-tsconfig-paths     # reuse tsconfig path aliases
npm install --save-dev @vitejs/plugin-legacy   # very old browsers only

2. Create vite.config.js

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
import { fileURLToPath, URL } from 'node:url';

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

  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },

  server: {
    port: 3000, // keep CRA's familiar port
    open: true,
    proxy: {
      // replaces CRA's "proxy" field or setupProxy.js
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },

  build: {
    outDir: 'build', // CRA's output folder, so deploy scripts keep working
    sourcemap: true,
  },
});

Fast Refresh is on by default in @vitejs/plugin-react; there is no option to set. If your CRA app used a proxy field in package.json, note that CRA forwarded the path unchanged, so do not add a rewrite unless your backend actually expects the prefix removed.

3. Move and Update index.html

Vite treats index.html as the entry point, so it moves from public/ to the project root, and it loads your entry file directly:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <script type="module" src="/src/index.jsx"></script>
  </body>
</html>

Key differences from CRA:

  • Remove %PUBLIC_URL%; files in public/ are served from /.
  • The <script type="module"> tag is required; CRA injected its script automatically.
  • Vite can also swap environment variables into index.html at build time: wrap the variable name (for example VITE_APP_TITLE) in percent signs where you need the value.

4. Update package.json Scripts

{
  "scripts": {
    "dev": "vite",
    "start": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest"
  }
}

Keeping a start alias avoids breaking team habits and existing docs.

5. Migrate Environment Variables

CRA exposes variables prefixed REACT_APP_ through process.env. Vite exposes variables prefixed VITE_ through import.meta.env. Your .env, .env.development and .env.production files keep their names; only the variable names change.

// CRA
const apiUrl = process.env.REACT_APP_API_URL;
const isProd = process.env.NODE_ENV === 'production';

// Vite
const apiUrl = import.meta.env.VITE_API_URL;
const isProd = import.meta.env.PROD; // also: DEV, MODE

A careful search-and-replace handles most of it. Replace the prefixed form first, so you do not blindly turn every process.env into import.meta.env:

# Rename the variables in env files
sed -i 's/^REACT_APP_/VITE_/' .env*

# Rewrite usages in source
grep -rl "process.env.REACT_APP_" src | xargs sed -i 's/process\.env\.REACT_APP_/import.meta.env.VITE_/g'

Then handle remaining process.env.NODE_ENV checks by hand using import.meta.env.DEV, PROD or MODE. Review the diff before committing.

Remember that anything prefixed VITE_ ends up in the JavaScript bundle and is visible to anyone. Never put secrets there, which was equally true of REACT_APP_.

For TypeScript, declare your variables in src/vite-env.d.ts:

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_ANALYTICS_ID: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

6. Rename JSX Files and Fix Imports

Unlike some guides suggest, you do not need to add file extensions to your imports. Vite resolves .js, .jsx, .ts, .tsx and .json automatically, so import Button from './components/Button' keeps working. What you do need is for files containing JSX to use .jsx or .tsx. Rename them (using git mv keeps history), and update src/index.js to src/index.jsx to match the script tag in index.html.

If you used CRA's absolute imports via baseUrl (for example import Button from 'components/Button'), either switch to the @ alias configured above or add vite-tsconfig-paths so Vite reads the same paths as TypeScript.

7. Update SVG Imports

CRA allowed import { ReactComponent as Logo } from './logo.svg'. With current versions of vite-plugin-svgr, add a ?react query and use the default export:

import Logo from './logo.svg?react';   // React component
import logoUrl from './logo.svg';       // URL string, as before

TypeScript users should add /// <reference types="vite-plugin-svgr/client" /> to vite-env.d.ts.

8. Replace Jest with Vitest

CRA's test runner disappears with react-scripts. Vitest is the natural replacement: it reuses your Vite config and has a Jest-compatible API, so most tests run with few changes.

npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom
// vite.config.js (add a test section)
export default defineConfig({
  // ...plugins, resolve, server, build as above
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/setupTests.js'],
  },
});

Your existing src/setupTests.js can usually stay; change its jest-dom import to import '@testing-library/jest-dom/vitest'. Replace jest.fn() and jest.mock() with vi.fn() and vi.mock(). If you use TypeScript, add /// <reference types="vitest/config" /> at the top of the config so the test key is typed.

Performance Tuning After the Switch

Code-Split by Route

Vite splits every dynamic import() into its own chunk. Lazy-loading route components is still the single most effective way to shrink the initial bundle:

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router';

const Dashboard = lazy(() => import('./features/dashboard/Dashboard'));
const Reports = lazy(() => import('./features/reports/Reports'));

function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/reports" element={<Reports />} />
      </Routes>
    </Suspense>
  );
}

Be Careful with Manual Chunks

Vite's default chunking is good. Only add manual vendor chunks if you have measured a problem, such as a large, rarely changing library being re-downloaded on every deploy. When you do, match exact package paths. A check like id.includes('react') also matches react-router, @emotion/react and dozens of other packages. Chunking options also differ between the Rollup and Rolldown eras, so follow the docs for your version.

Inspect the Bundle

Use rollup-plugin-visualizer (or vite-bundle-visualizer) to see what is in each chunk before optimising. A heavy date library, an icon set imported in full or a charting library on the landing page usually explains most bundle bloat.

Dependency Pre-Bundling

If a dependency is discovered late and causes a full page reload during development, add it to optimizeDeps.include. If you edit a linked package locally and changes do not appear, restart with vite --force to rebuild the cache.

Deployment

Serve the SPA Correctly

The build/ output contains index.html plus hashed files in build/assets/. Hashed assets can be cached for a long time; index.html should not be. For nginx:

server {
  listen 80;
  server_name example.com;
  root /var/www/build;
  index index.html;

  location / {
    try_files $uri $uri/ /index.html;
  }

  location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

If the app is served from a sub-path, set base in the Vite config (the equivalent of CRA's homepage field).

Update CI

# .github/workflows/build.yml
name: Build
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test -- --run
      - run: npm run build
        env:
          VITE_API_URL: ${{ vars.API_URL }}

Note the variable rename to VITE_ in your CI settings and hosting provider too. A build that silently reads undefined for the API URL is a common first-deploy surprise.

Migration Checklist

Before you start

  • List CRACO, rewired or ejected customisations
  • Grep for process.env, require(, ReactComponent and %PUBLIC_URL%
  • Confirm your Node version meets Vite's requirement

Core migration

  • Replace react-scripts with vite and @vitejs/plugin-react
  • Create vite.config.js (aliases, proxy, outDir)
  • Move index.html to the root and add the module script tag
  • Rename files containing JSX to .jsx/.tsx
  • Rename REACT_APP_ variables to VITE_ and use import.meta.env
  • Update SVG component imports
  • Move tests to Vitest

Verify

  • Dev server, HMR and every major route work
  • vite build and vite preview work and the bundle size looks sensible
  • Environment variables resolve in staging
  • Server rewrites, caching headers and CI are updated
  • Old config files (craco.config.js, config-overrides.js) are deleted

Results and Takeaways

For most teams, the clearest gain is the day-to-day development loop. The dev server comes up almost instantly, edits show up immediately, and builds in CI are usually noticeably quicker. Just as important, you end up with a maintained toolchain with a configuration file you can read and change, instead of a deprecated wrapper around an old Webpack setup.

A few lessons that come up again and again:

  • Most of the work is mechanical. Environment variables, index.html, JSX file extensions and SVG imports account for the bulk of the changes.
  • Tests are the long tail. Moving from Jest to Vitest is usually straightforward, but mocks and custom setup take the most time.
  • Measure before and after. Record dev start time, build time and bundle size before you migrate, so you can see the real improvement for your codebase.
  • Consider whether you need a framework. If you need server rendering, SEO-critical pages or server-side data loading, the React team recommends a framework instead of a plain SPA. For dashboards and internal tools, Vite plus a client-side router is often the simplest fit.

If you are planning a migration or a frontend rebuild and want an experienced team to help, see our web application development services or book a free consultation with TrackMy Tech.

←BACK TO BLOG
STAY UPDATED

MORE FROM THE PRODUCT BLOG

SEE ALL POSTS