The Complete Guide to Migrating from Create React App to Vite
Step-by-step instructions to modernize your React build pipeline
Create React App is dead. React itself has removed CRA from its official documentation, the repository is barely maintained, and the developer experience has fallen years behind modern alternatives. If you're still running CRA in production, you're dealing with painfully slow builds, bloated configurations, and a toolchain that's holding your team back.
The good news: migrating to Vite is one of the highest-ROI changes you can make to your frontend codebase. I've done this migration for multiple clients, and the results are consistently dramatic — 10-20x faster dev server startup, 5-10x faster production builds, and significantly happier developers.
This guide walks through the entire migration process, step by step, based on real-world experience.
Why Migrate?
Before we dive into the how, let's be clear about the why.
Build Performance
Here are real numbers from a recent client migration (medium-sized SaaS app, ~800 components):
| Metric | CRA | Vite | Improvement | |---|---|---|---| | Dev server cold start | 45s | 1.2s | 37x faster | | Hot Module Replacement | 4-8s | <50ms | ~100x faster | | Production build | 3m 20s | 22s | 9x faster | | Bundle size | 2.1MB | 1.4MB | 33% smaller |
These aren't cherry-picked numbers. They're typical for a medium-complexity React application.
Developer Experience
- Instant server start. Vite serves source files over native ESM — no bundling required during development.
- True HMR. Changes reflect in the browser in under 50ms, regardless of app size.
- Better error messages. Vite's error overlay is clearer and more actionable than CRA's.
- Modern defaults. ESM, tree-shaking, code splitting — all optimized out of the box.
Ecosystem Support
CRA's last meaningful update was over two years ago. Vite, by contrast, has an active ecosystem with plugins for everything you need, first-class TypeScript support, and a community that's shipping improvements weekly.
Pre-Migration Checklist
Before you start, audit your current setup:
- [ ] Node.js version: Vite requires Node 18+. Check with
node --version. - [ ] Custom webpack config? If you've ejected CRA or use
craco/react-app-rewired, list every customization. Each one needs a Vite equivalent. - [ ] Environment variables: List all
REACT_APP_*variables. They'll need renaming. - [ ] Testing setup: If you're using Jest (CRA default), plan for the testing migration.
- [ ] Proxy configuration: If you have a proxy setup in
package.json, note the target URLs. - [ ] CI/CD pipeline: Note all build-related commands in your pipeline.
Step-by-Step Migration
Step 1: Install Vite and Dependencies
# Remove CRA dependencies
npm uninstall react-scripts
# Install Vite and plugins
npm install -D vite @vitejs/plugin-react
# If using TypeScript (you should be)
npm install -D @types/node
Step 2: Create Vite Configuration
Create vite.config.ts in your project root:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000, // Match your current CRA port
proxy: {
// If you had a proxy in package.json
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
},
},
},
build: {
outDir: 'build', // Match CRA's output directory, or change to 'dist'
sourcemap: true,
},
});
Step 3: Move and Update index.html
CRA keeps index.html in the public/ folder. Vite expects it at the project root.
mv public/index.html ./index.html
Then update index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Your App</title>
</head>
<body>
<div id="root"></div>
<!-- This is the key addition - Vite needs this script tag -->
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
Key changes:
- Remove
%PUBLIC_URL%references — Vite handles public assets automatically - Add the
<script type="module">tag pointing to your entry file - Remove any
%REACT_APP_*%template variables from HTML
Step 4: Update Environment Variables
This is the most error-prone step. CRA uses REACT_APP_ prefix; Vite uses VITE_.
# .env (before)
REACT_APP_API_URL=https://api.example.com
REACT_APP_GA_ID=G-XXXXXXXXXX
REACT_APP_SENTRY_DSN=https://xxx@sentry.io/xxx
# .env (after)
VITE_API_URL=https://api.example.com
VITE_GA_ID=G-XXXXXXXXXX
VITE_SENTRY_DSN=https://xxx@sentry.io/xxx
Then update every reference in your code:
// Before
const apiUrl = process.env.REACT_APP_API_URL;
// After
const apiUrl = import.meta.env.VITE_API_URL;
Pro tip: Use a find-and-replace across your entire codebase. But do it in two passes:
- Replace
process.env.REACT_APP_withimport.meta.env.VITE_ - Manually review each change — some might be in conditional logic that needs attention
For TypeScript, add type definitions. Create or update src/vite-env.d.ts:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_GA_ID: string;
readonly VITE_SENTRY_DSN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Step 5: Update package.json Scripts
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx"
}
}
Remove any CRA-specific scripts (react-scripts start, react-scripts build, react-scripts test).
Step 6: Handle Static Assets
CRA has special handling for assets in the public/ folder and imports from src/. Vite handles these differently:
// Importing images/assets - works the same way
import logo from './logo.svg';
// But for assets in public/, remove %PUBLIC_URL%
// Before (in JSX): <img src={process.env.PUBLIC_URL + '/logo.png'} />
// After: <img src="/logo.png" />
Step 7: Update SVG Handling
CRA includes built-in SVG-as-component support. With Vite, you need a plugin:
npm install -D vite-plugin-svgr
// vite.config.ts
import svgr from 'vite-plugin-svgr';
export default defineConfig({
plugins: [
react(),
svgr(), // Add this
],
});
// Now SVG imports work like CRA
import { ReactComponent as Logo } from './logo.svg'; // Named import
import logoUrl from './logo.svg'; // URL import (default)
Step 8: Update Path Aliases
If you were using CRA's built-in src/ import support or craco aliases:
// tsconfig.json - add/update paths
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
The Vite config alias we set up in Step 2 handles the runtime resolution. The tsconfig paths handle TypeScript's understanding of the aliases.
Migrating Your Test Setup
CRA ships with Jest pre-configured. When you remove react-scripts, your test setup breaks. You have two options:
Option A: Keep Jest (Less Migration Work)
npm install -D jest @testing-library/react @testing-library/jest-dom
npm install -D ts-jest @types/jest identity-obj-proxy
Create jest.config.ts:
export default {
testEnvironment: 'jsdom',
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/src/__mocks__/fileMock.ts',
},
setupFilesAfterSetup: ['<rootDir>/src/setupTests.ts'],
};
Option B: Migrate to Vitest (Recommended)
Vitest is built on top of Vite, so it shares your Vite config and offers near-instant test startup:
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vite.config.ts
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
css: true,
},
});
The best part: Vitest is API-compatible with Jest. Most of your existing tests will work without changes. The main differences:
// Jest: globals available automatically
// Vitest: import explicitly (or set globals: true in config)
import { describe, it, expect, vi } from 'vitest';
// jest.fn() → vi.fn()
// jest.mock() → vi.mock()
// jest.spyOn() → vi.spyOn()
CI/CD Changes
Your CI pipeline will need updates. Here's a typical before/after:
# Before (GitHub Actions with CRA)
- name: Build
run: npm run build
env:
CI: true
REACT_APP_API_URL: ${{ secrets.API_URL }}
# After (GitHub Actions with Vite)
- name: Build
run: npm run build
env:
VITE_API_URL: ${{ secrets.API_URL }}
Key changes:
- Remove
CI=true(CRA used this to treat warnings as errors — configure ESLint separately if you want this behavior) - Rename all
REACT_APP_*env vars toVITE_* - Update build output directory if you changed it (
build/vsdist/) - Update any deploy commands that reference the output directory
Common Pitfalls
1. Global CSS Import Order
Vite processes CSS imports differently than CRA. If you have CSS ordering issues after migration, make sure your global CSS is imported first in your entry file:
// src/main.tsx (entry point)
import './index.css'; // Global styles first
import './App.css';
import App from './App';
2. process.env References
Vite doesn't polyfill process.env like CRA does. If you have library code that references process.env.NODE_ENV, add a define in your Vite config:
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
});
3. CommonJS Dependencies
Some older npm packages use CommonJS (require()) instead of ESM (import). Vite handles most of these automatically through its dependency pre-bundling, but occasionally you'll hit issues. The fix is usually adding the problematic package to optimizeDeps:
export default defineConfig({
optimizeDeps: {
include: ['problematic-package'],
},
});
4. Dynamic Imports with Variables
CRA (webpack) supports dynamic imports with expressions. Vite is stricter:
// This works in CRA but may fail in Vite
const module = await import(`./pages/${pageName}`);
// Vite needs glob imports for this pattern
const pages = import.meta.glob('./pages/*.tsx');
const module = await pages[`./pages/${pageName}.tsx`]();
5. Missing TypeScript Config for Vite
Make sure your tsconfig includes Vite's client types. Remove react-scripts from types if it's there:
{
"compilerOptions": {
"types": ["vite/client"]
}
}
Performance Comparison: Real Numbers
After completing the migration for a client's 800-component SaaS application, we benchmarked everything:
Development:
- Cold start: 45s -> 1.2s (the single biggest quality-of-life improvement)
- HMR: 4-8s -> ~50ms (changes feel instant)
- Memory usage: 1.8GB -> 400MB (significant for developer machines)
Production Builds:
- Build time: 3m 20s -> 22s (CI pipeline runs are noticeably faster)
- Bundle size: 2.1MB -> 1.4MB (better tree-shaking with Rollup)
- Lighthouse score: 72 -> 89 (mostly from smaller bundles and better code splitting)
Developer Satisfaction: We ran a survey before and after. The results were unanimous — every developer on the team reported a meaningfully better development experience. The instant HMR alone changed how people worked; developers started making smaller, more frequent changes because the feedback loop was so fast.
Migration Timeline
For a typical medium-sized React application:
- Day 1: Install Vite, create config, move index.html, update env vars
- Day 2: Fix asset handling, SVG imports, path aliases
- Day 3: Migrate test setup, fix any failing tests
- Day 4: Update CI/CD pipeline, test production build
- Day 5: QA, fix edge cases, deploy to staging
For larger applications with heavy webpack customization, add another week for handling custom plugins and configurations.
Wrapping Up
Migrating from CRA to Vite is one of the most impactful improvements you can make to your frontend development workflow. The migration itself is straightforward — most of the work is mechanical find-and-replace operations. The payoff is immediate: faster builds, happier developers, and a modern toolchain that will serve you well for years.
If you're putting off this migration because it seems risky, start with a proof-of-concept branch. Get the dev server running with Vite and see the speed difference for yourself. Once you experience sub-second hot reloads, you won't want to go back.