Skip to main content

How to Migrate from Create React App to Vite

·PkgPulse Team
0

TL;DR

CRA to Vite migration takes 30-60 minutes and gives you a 40x faster dev server. Create React App was deprecated in 2023 and should not be used for new projects or maintained indefinitely. The migration is well-documented and mechanical: remove react-scripts, install Vite, move index.html, rename env vars from REACT_APP_ to VITE_, update imports from process.env to import.meta.env. Most apps migrate with under 20 file changes.

Key Takeaways

  • Dev server: 8,000ms → 200ms — CRA bundles everything; Vite serves ESM natively
  • 5 main changes: react-scriptsvite, index.html location, env vars, tsconfig, scripts
  • Tests: CRA used Jest; switch to Vitest (see Jest → Vitest guide) or keep Jest standalone
  • CRA was deprecated 2023 — no security patches on CRA vulnerabilities
  • TypeScript CRA: same migration, just add TypeScript-specific steps

Prerequisites and Assessment

Before you start, spend a few minutes auditing your current project. This assessment prevents surprises halfway through the migration.

Check your CRA version and scripts. Open package.json and look at the react-scripts version and the scripts block. CRA projects typically look like this:

{
  "dependencies": {
    "react-scripts": "5.0.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  }
}

Note that react-scripts 5.x ships Webpack 5, Babel 7, and ESLint 8 under the hood. All of this gets replaced by Vite's ESBuild-based pipeline. This is the reason the dev server becomes 40x faster — Vite uses native ES modules in development and never bundles during dev, while CRA recompiles the entire dependency graph on every change.

Why you should migrate now. Create React App was formally deprecated by the React team in March 2023. The react-scripts package has not received security updates since then. The npm audit for a fresh CRA project in 2026 shows dozens of high-severity vulnerabilities. Vite, by contrast, releases regularly and powers the scaffolding for React, Vue, Svelte, and most modern frameworks.

Inventory your dependencies. Check for these CRA-specific things that need attention:

  • process.env.REACT_APP_* env vars — these need renaming to VITE_*
  • SVG imports as React components — CRA had built-in support; Vite needs a plugin
  • public/index.html — this file moves to the project root in Vite
  • %PUBLIC_URL% template strings — these get stripped out
  • require() calls — CommonJS require() doesn't work in Vite's ESM environment without configuration
  • Jest tests — CRA wired up Jest automatically; Vite does not. You'll need to set up Vitest or configure Jest standalone.

With a list of these items in hand, you can estimate your migration time. A typical CRA project with standard setup finishes in under an hour. Projects that rely heavily on custom Webpack configuration (via craco or react-app-rewired) may take longer because you'll need to translate those overrides into Vite plugins.


Step 1: Remove react-scripts and Install Vite

Start by uninstalling the CRA toolchain entirely. There is no partial migration — react-scripts and Vite cannot coexist as your primary build tool.

# Remove CRA
npm uninstall react-scripts

# Verify CRA is gone
grep "react-scripts" package.json  # Should show nothing

Now install Vite and the React plugin:

npm install -D vite @vitejs/plugin-react

# For TypeScript projects (most CRA projects are TypeScript):
npm install -D vite @vitejs/plugin-react typescript

# Recommended extras:
npm install -D vite-tsconfig-paths   # Reads path aliases from tsconfig
npm install -D vite-plugin-svgr      # SVG as React components (if you use SVG imports)

The @vitejs/plugin-react plugin provides Fast Refresh (HMR), JSX transformation, and Babel integration for advanced transforms. vite-tsconfig-paths automatically picks up paths aliases from your tsconfig.json so you don't need to duplicate them in vite.config.ts.

Update your package.json scripts:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest run"
  }
}

The tsc && prefix on the build command runs the TypeScript compiler to type-check before building. Vite itself does not type-check — it strips types without checking them, leaving that to tsc.


Step 2: Create vite.config.ts

Create vite.config.ts at the project root. This is the complete configuration for a CRA-equivalent setup:

// vite.config.ts — at project root
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import svgr from 'vite-plugin-svgr';  // Only if you import SVGs as React components

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths(),   // Handles paths from tsconfig.json
    svgr(),            // If you use: import { ReactComponent as Logo } from './logo.svg'
  ],
  resolve: {
    alias: {
      '@': '/src',  // Optional: manual alias if not using vite-tsconfig-paths
    },
  },
  server: {
    port: 3000,       // Match CRA's default port
    open: true,       // Auto-open browser on dev start
    proxy: {
      // Proxy API calls to avoid CORS in development
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
  build: {
    outDir: 'build',   // CRA used 'build'; Vite defaults to 'dist' — match CRA
    sourcemap: true,
  },
});

Key decisions to understand in this config:

  • server.port: 3000 — CRA defaults to port 3000. Vite defaults to 5173. Set this explicitly so you don't break saved browser bookmarks or OAuth redirect URIs during development.
  • build.outDir: 'build' — CRA outputs to build/. Vite defaults to dist/. If your deployment pipeline references build/ (common in Netlify, Vercel, and CI scripts), keep this set to 'build'.
  • proxy — CRA's package.json "proxy" field had built-in proxy support. Vite's equivalent is server.proxy in vite.config.ts.

Step 3: Move index.html to the Project Root

CRA keeps index.html in the public/ directory. Vite expects it at the project root. This is the most structurally different change in the migration.

mv public/index.html ./index.html

After moving, edit index.html to make two changes: remove %PUBLIC_URL% prefixes and add the Vite module script entry point.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <!-- Before: href="%PUBLIC_URL%/favicon.ico" -->
    <!-- After: root-relative path, no variable -->
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <!-- Before: href="%PUBLIC_URL%/manifest.json" -->
    <link rel="manifest" href="/manifest.json" />
    <title>React App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!-- ADD THIS — Vite requires an explicit entry point script tag -->
    <!-- CRA injected this automatically; Vite requires it explicitly -->
    <script type="module" src="/src/index.tsx"></script>
    <!-- Use .tsx for TypeScript, .jsx for JavaScript -->
  </body>
</html>

The two critical changes are:

  1. All %PUBLIC_URL% occurrences become simple root-relative paths like /favicon.ico. Search and replace this across the entire file.
  2. The <script type="module" src="/src/index.tsx"> tag is essential. Without it, Vite has no entry point and the page will be blank.

Other files in public/ (images, icons, manifest.json, robots.txt) stay in public/ and Vite copies them to the output directory automatically.


Step 4: Rename Environment Variables

CRA required the REACT_APP_ prefix for custom environment variables. Vite uses VITE_. Any variable that does not have this prefix is not exposed to the browser bundle, which is a security feature — it prevents accidentally leaking server secrets.

Update your .env files:

# Before (.env, .env.local, .env.development, .env.production):
REACT_APP_API_URL=https://api.example.com
REACT_APP_GOOGLE_MAPS_KEY=abc123
REACT_APP_STRIPE_PUBLIC_KEY=pk_live_...

# After:
VITE_API_URL=https://api.example.com
VITE_GOOGLE_MAPS_KEY=abc123
VITE_STRIPE_PUBLIC_KEY=pk_live_...

Update all usages in your source code. The access pattern also changes — from process.env to import.meta.env:

// Before (CRA):
const apiUrl = process.env.REACT_APP_API_URL;
const mapsKey = process.env.REACT_APP_GOOGLE_MAPS_KEY;

// Check runtime environment:
if (process.env.NODE_ENV === 'development') {
  console.log('dev mode');
}
if (process.env.NODE_ENV === 'production') {
  // do something
}

// After (Vite):
const apiUrl = import.meta.env.VITE_API_URL;
const mapsKey = import.meta.env.VITE_GOOGLE_MAPS_KEY;

// Vite provides boolean flags instead of the string 'development':
if (import.meta.env.DEV) {
  console.log('dev mode');
}
if (import.meta.env.PROD) {
  // do something
}

// Mode is still available as a string if you need it:
if (import.meta.env.MODE === 'staging') {
  // custom mode
}

For TypeScript, add a src/vite-env.d.ts file to get proper type checking on your env vars:

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_GOOGLE_MAPS_KEY: string;
  readonly VITE_STRIPE_PUBLIC_KEY: string;
  // Add all your VITE_ variables here for full type safety
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

This gives you autocomplete and type errors when you mistype an env variable name.


Step 5: Update tsconfig.json

CRA's tsconfig.json contains settings that are incompatible with Vite's build process. Replace it with a Vite-optimized configuration:

// tsconfig.json — Vite-compatible
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    // Key change: "bundler" resolution is Vite-native
    // CRA used "node" or "node16" — change this
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,

    // Vite handles transpilation; tsc only type-checks
    "noEmit": true,
    "jsx": "react-jsx",

    // Strict settings (keep from CRA):
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

    // Add Vite's client types for import.meta.env
    "types": ["vite/client"]
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

Also create tsconfig.node.json for the Vite config file itself:

// tsconfig.node.json
{
  "compilerOptions": {
    "composite": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true
  },
  "include": ["vite.config.ts"]
}

The critical setting change is "moduleResolution": "bundler". CRA used "node" resolution which is CommonJS-oriented. "bundler" is designed for tools like Vite that use native ESM and allows importing TypeScript files with their .ts extensions explicitly.


Step 6: Handle SVG Imports

CRA had a built-in Webpack loader that let you import SVG files as React components using a named ReactComponent export. This is not a Web standard — it was CRA magic. Vite does not include this by default.

Install vite-plugin-svgr if you haven't already:

npm install -D vite-plugin-svgr

With the plugin configured in vite.config.ts (shown in Step 2), your SVG imports need a small syntax change:

// CRA: SVG as React component using named export
import { ReactComponent as Logo } from './assets/logo.svg';

// Vite with vite-plugin-svgr: use the ?react query parameter
import Logo from './assets/logo.svg?react';

// Usage is identical:
function Header() {
  return <Logo className="h-8 w-8" />;
}

If you just want to use an SVG as an image URL (not as a React component), Vite handles that natively without any plugin:

// Import SVG as a URL string — works in Vite without any plugin
import logoUrl from './assets/logo.svg';

function Header() {
  return <img src={logoUrl} alt="Logo" className="h-8 w-8" />;
}

Use the ?react pattern only when you need to manipulate the SVG's internals (changing colors via CSS, animating paths). For static logos and icons, the plain import is simpler.


Common Pitfalls

These are the issues that catch developers during migration and are not obvious from the documentation.

require() doesn't work. Vite runs in native ESM mode. CommonJS require() calls will throw a ReferenceError: require is not defined at runtime in the browser. Convert all require() to import:

// Before:
const config = require('./config.json');
const lodash = require('lodash');

// After:
import config from './config.json';
import lodash from 'lodash';

Node.js built-ins (require('path'), require('fs')) are server-side only and cannot run in the browser regardless of the bundler.

process.env is undefined. Vite does not polyfill process.env globally. Only import.meta.env works. If you use a library that internally references process.env.NODE_ENV, Vite will inline the value at build time — but your own code must use import.meta.env.

Jest tests break. CRA configured Jest with a custom transformer that handled JSX and TypeScript. When you remove react-scripts, Jest loses its configuration. You have two options: migrate to Vitest (the Vite-native test runner with a Jest-compatible API), or configure Jest manually with babel-jest or ts-jest. The Vitest migration is usually straightforward and worth doing at the same time.

Absolute imports stop working. If you had "paths": {"@/*": ["src/*"]} in tsconfig.json, those work at type-check time but Vite needs to know about them separately. The vite-tsconfig-paths plugin reads your tsconfig.json paths and makes them work in Vite's bundler. Add it to vite.config.ts plugins as shown in Step 2.

homepage in package.json. CRA read the homepage field to set the base URL for production builds. Vite uses base in vite.config.ts:

// vite.config.ts
export default defineConfig({
  base: '/my-app/',  // Replaces "homepage": "/my-app/" in CRA's package.json
});

Verification

After completing all steps, run these checks to confirm a successful migration:

# Start the dev server
npm run dev
# Should start in under 500ms at http://localhost:3000

# Run type checking
npx tsc --noEmit
# Should show no TypeScript errors

# Build for production
npm run build
# Should complete, output in /build directory

# Preview the production build locally
npm run preview
# Should serve from /build at http://localhost:4173

# Check for CRA remnants
grep -r "react-scripts\|REACT_APP_\|process\.env\.NODE_ENV\|%PUBLIC_URL%" src/
# Should return no matches

Check the terminal output carefully on npm run build. Vite reports bundle size by chunk. If you see chunks over 500KB, consider adding code splitting with dynamic import() to lazy-load heavy routes.


Understanding the Performance Difference

The 40x startup time improvement is not marketing — it reflects a fundamental architectural change in how development servers work.

CRA wraps Webpack, which is a module bundler. In development mode, Webpack processes every file in your src/ directory, resolves all imports, applies all loaders (Babel for JSX/TypeScript, CSS modules, SVG loaders), and bundles everything into a single JavaScript file before serving the first request. For a medium-sized app with 200 components, this initial bundle compilation takes 5-15 seconds. Every time you change a file, Webpack recompiles the entire affected bundle.

Vite takes a different approach. In development, it starts an HTTP server and serves files individually using native browser ES modules. When the browser requests http://localhost:3000, Vite serves index.html. The browser parses the HTML, finds <script type="module" src="/src/index.tsx">, and requests that file. Vite transforms only that file (TypeScript stripping + JSX transform using ESBuild, which is 10-100x faster than Babel), and serves it. The browser then parses the imports in that file and requests each dependency. Vite transforms each one on demand.

The result: first meaningful render appears in under 500ms regardless of project size, because Vite only processes what the browser actually requests. A 500-component app starts just as fast as a 10-component app, because only the components visible in the current route get processed initially.

Hot module replacement (HMR) is similarly faster. When you save a file, Vite only transforms that one file and sends the update to the browser. The browser replaces just that module without a full reload. CRA's HMR recompiles the entire bundle chunk containing the changed file, which can still take 1-2 seconds in a large app.

For production builds, both CRA and Vite create optimized bundles. Vite uses Rollup for production (not ESBuild), which produces highly optimized code with excellent tree-shaking. The production bundle sizes are comparable between CRA and Vite, but Vite's build is typically faster.


Handling Tests After Migration

CRA configured Jest with a custom transformer that handled JSX and TypeScript automatically. When you remove react-scripts, Jest no longer knows how to parse .tsx files. You have two paths forward.

Option A: Migrate to Vitest. Vitest is Vite's test runner. It uses the same configuration and plugins as Vite, so it works out of the box with your vite.config.ts. The API is Jest-compatible, meaning describe, test, expect, vi.fn() (instead of jest.fn()), and vi.mock() (instead of jest.mock()) — most tests migrate with a global find-and-replace. Install it with npm install -D vitest @vitest/ui jsdom, add "test": "vitest" to your scripts, and add a test config to vite.config.ts:

// vite.config.ts — add test block
export default defineConfig({
  // ... existing config
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.ts',
  },
});

Option B: Keep Jest standalone. If you have a large Jest test suite and want to avoid migrating at the same time as the Vite migration, configure Jest independently. Install @babel/preset-react, @babel/preset-typescript, babel-jest, and create a babel.config.js. This is more setup work but keeps your tests untouched.

Most teams find the Vitest migration straightforward enough to do at the same time as the Vite migration, since the API compatibility is high.


When to Choose Vite vs Other Alternatives

If your CRA project is growing into a larger application and you are evaluating your options beyond just "migrate to Vite," here is a quick comparison:

ScenarioBest Choice
Standard React SPA migration from CRAVite
Full-stack with API routes and SSRNext.js
Need Turbopack's incremental compilation for very large appsNext.js 15+
Monorepo with shared packagesVite + Turborepo
Electron desktop appVite (excellent Electron support)

For most CRA migrations, Vite is the correct destination. It is the officially recommended replacement and what the React team now points to in their documentation.


Further Reading

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.