webpack to Vite skill for Codex

Leave Create React App without breaking prod. Codex starts by scanning your source with a bundled audit script that flags every webpack-only pattern, from require.context and module.hot to REACT_APP_ variables and JSX in .js files. It then moves index.html, ports aliases, proxy and env handling into vite.config.ts, maps each loader to its Vite equivalent, and checks the new build against a recorded baseline before anything is deleted.

  • Frontend
  • $webpack-to-vite
  • 190 lines
  • 3 files
  • ~2,300 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- webpack-to-vite

Installs to ~/.agents/skills/webpack-to-vite/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $webpack-to-vite, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Migrates Create React App (react-scripts), CRACO and hand-rolled webpack 4/5 builds to Vite - moves index.html, maps loaders and plugins to Vite equivalents, converts process.env and require.context, swaps Jest for Vitest, and proves parity before deleting the old config. Ships an audit script that lists every webpack-only pattern in the source. Use when asked to move off CRA or webpack, when react-scripts blocks a React or Node upgrade, or when dev server start times are too slow.

Use it when

  • A project still depends on react-scripts or CRACO
  • A custom webpack build is slow or hard to maintain
  • Dependency upgrades are blocked by an unmaintained webpack toolchain

Use something else when

How to check the result

The audit script exits 0, vitest passes, and vite preview renders every baseline route with matching env-driven behaviour and a comparable bundle size.

The mistake it prevents

Shimming process.env globally with define to make errors go away, which hides missed conversions and can inline server-only values into the client bundle.

~/.agents/skills/webpack-to-vite/SKILL.md

--- name: webpack-to-vite description: Migrates Create React App (react-scripts), CRACO and hand-rolled webpack 4/5 builds to Vite - moves index.html, maps loaders and plugins to Vite… license: MIT ---

The migration is done when the Vite build serves every route the webpack build did, the same env values reach the app, tests run, the deploy pipeline publishes the new output, and the webpack config is deleted in a separate, reviewable commit. A half-migrated repo with two bundlers and no owner is worse than either.

Before changing anything

  1. Read AGENTS.md, package.json scripts, the CI workflow and the deploy config (Dockerfile, Netlify/Vercel settings, nginx) to learn where build/ or dist/ is consumed.
  2. Record a baseline with the current build: npm run build, note output size per entry, build time, the env vars it reads, and a list of routes to smoke test.
  3. Run the audit script from this skill on the project root:
python3 scripts/audit_webpack_usage.py <project-dir>          # human readable
python3 scripts/audit_webpack_usage.py <project-dir> --json   # for tracking

It reports BLOCK findings (code that will fail under Vite: require(), require.context, module.hot, process.env.REACT_APP_*, JSX in .js, ~ Sass imports, %PUBLIC_URL%, SVG ReactComponent imports, webpack config files) and warn findings (Node built-ins, global, magic comments). Exit code 1 means blocking work remains. Re-run it until it exits 0. 4. Work on a branch. Installing Vite and plugins needs network; in a sandbox without it, ask for approval or have the user run the install.

Migration checklist

  • Install: vite, the framework plugin (@vitejs/plugin-react or @vitejs/plugin-vue), vitest, jsdom if tests need it
  • Move public/index.html to the project root and add the module entry
  • Write vite.config.ts that ports aliases, proxy, defines and output dir
  • Convert env access and rename env vars
  • Fix every BLOCK finding from the audit
  • Port tests from Jest (react-scripts) to Vitest
  • Update scripts, CI cache paths and deploy output dir
  • Parity check against the baseline
  • Remove react-scripts / webpack deps and configs in a final commit, after user sign-off

index.html

<!-- project root: index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />          <!-- was %PUBLIC_URL%/favicon.ico -->
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.jsx"></script>
  </body>
</html>

Files in public/ keep working and are served from /. index.html must leave public/, or Vite copies it verbatim instead of processing it.

Config that matches CRA defaults

// vite.config.ts
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: {
    // CRA's jsconfig "baseUrl": "src" allowed `import Button from 'components/Button'`
    alias: { components: fileURLToPath(new URL('./src/components', import.meta.url)) },
  },
  envPrefix: ['VITE_', 'REACT_APP_'],   // transitional: keep old names working, rename later
  server: {
    port: 3000,
    proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }, // from "proxy" or setupProxy.js
  },
  build: { outDir: 'build' },           // keep the deploy pipeline unchanged for now
  test: {
    environment: 'jsdom',
    globals: true,                      // keeps Jest-style describe/it/expect without imports
    setupFiles: './src/setupTests.js',
  },
});

Add /// <reference types="vitest/config" /> at the top if TypeScript complains about test. Scripts become "dev": "vite", "build": "vite build", "preview": "vite preview", "test": "vitest".

Source conversions

webpack / CRAVite
process.env.REACT_APP_Ximport.meta.env.REACT_APP_X (with envPrefix) then rename to VITE_X
process.env.NODE_ENV === 'production'import.meta.env.PROD
process.env.PUBLIC_URLimport.meta.env.BASE_URL
require('./img.png')import img from './img.png' or new URL('./img.png', import.meta.url).href
require.context('./locales', true, /\.json$/)import.meta.glob('./locales/**/*.json', { eager: true })
module.hot.accept()delete (React plugin handles Fast Refresh) or import.meta.hot
import { ReactComponent as Logo } from './logo.svg'import Logo from './logo.svg?react' with vite-plugin-svgr v4+
JSX inside .js filesrename to .jsx (git mv keeps history); bulk rename with a script, then fix imports that include the extension
@import '~bootstrap/scss/bootstrap'@import 'bootstrap/scss/bootstrap'
import worker from 'worker-loader!./w'new Worker(new URL('./w.js', import.meta.url), { type: 'module' })
raw-loaderimport text from './file.txt?raw'

For env access spread across many files, use a codemod or a careful sed with review; do not shim process.env globally with define, it hides missed conversions and can leak server-side variables into the bundle.

Loader and plugin mapping (custom webpack)

webpackVite equivalent
babel-loader + preset-react/typescriptFramework plugin; TS is transpiled natively (type-check separately with tsc --noEmit)
Custom Babel plugins (emotion, styled-components, React Compiler)Configure through the installed @vitejs/plugin-react version’s documented Babel hook; check its README, the API changed across majors
css-loader modules*.module.css works out of the box
sass-loader / less-loaderInstall sass-embedded (or sass) / less; no loader config
file-loader / url-loaderNative asset imports; build.assetsInlineLimit (default 4 KiB)
DefinePlugindefine (JSON-encode values) or env vars
ProvidePlugin ($, React)Explicit imports; automatic JSX runtime removes the need for React in scope
HtmlWebpackPluginRoot index.html; multiple pages via build.rollupOptions.input (rolldownOptions on Vite 8)
CopyWebpackPluginpublic/, or vite-plugin-static-copy
splitChunksDynamic import() per route; manual chunking only if measured
Module Federation@module-federation/vite; plan it as its own step
Node polyfills (webpack 4 auto)Not provided. Replace the dependency or add specific polyfills
browserslist targeting old browsersbuild.target; @vitejs/plugin-legacy if you truly need non-ESM browsers

Tests: Jest to Vitest

  • jest.fn() to vi.fn(), jest.mock() to vi.mock() (hoisted the same way), jest.useFakeTimers() to vi.useFakeTimers(), jest.requireActual to await vi.importActual.
  • @testing-library/jest-dom: import @testing-library/jest-dom/vitest in the setup file.
  • CRA’s moduleNameMapper for CSS and images is unnecessary; Vitest handles them through Vite.
  • If the suite is large, migrate tests in a follow-up PR and keep Jest temporarily with babel-jest; do not block the build migration on 2,000 test files.

Gotchas

  • Vite dev serves native ESM: a CommonJS-only dependency that imports fine in webpack can fail in dev only. Add it to optimizeDeps.include.
  • Circular imports that webpack tolerated can produce undefined at module init under ESM ordering. Fix the cycle; do not reorder imports until it works.
  • Case-insensitive file systems hide import './Button' vs button.jsx mismatches that break on Linux CI. Build in CI before merging.
  • Code that reads process.env at runtime in the browser was always a webpack fiction. If the app needs runtime config per environment, fetch it instead of rebuilding.
  • homepage in package.json set CRA’s public path; in Vite it is base.
  • Service workers from the CRA PWA template need vite-plugin-pwa; do not ship a stale service-worker.js that caches the old asset names.

Parity check

npm run build && npx vite preview --port 4173 --strictPort
python3 scripts/audit_webpack_usage.py .      # must exit 0
npx vitest run
  • Every baseline route renders, no console errors, assets (fonts, images, SVG) load
  • Env-dependent behaviour matches (API base URL, feature flags, analytics keys)
  • Main entry size within about 10% of baseline, or the difference is explained
  • CI builds from a clean clone; deploy config points at the right output dir

Deleting react-scripts, webpack.config.js, craco.config.js and changing the deploy output directory affect production: show the diff and get the user’s confirmation first.

Report

## webpack to Vite migration
- Build time: <before> -> <after>; dev cold start: <before> -> <after>
- Main entry: <size before> -> <size after>
- Env vars renamed: REACT_APP_X -> VITE_X (list)
- Dependencies removed / added: ...
- Audit script: exit 0
- Not migrated yet / follow-ups: <tests still on Jest, legacy browser support, ...>

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "webpack to Vite"
  short_description: "Leave Create React App without breaking prod"
  default_prompt: "$webpack-to-vite Migrate this Create React App project to Vite and keep our deploy working"
scripts/audit_webpack_usage.py120 lines, 5.7 KB
#!/usr/bin/env python3
"""Scan a webpack or Create React App project for code that needs changes before Vite.

Usage: python3 audit_webpack_usage.py <project-dir> [--json]

Prints one line per finding (file:line  rule  hint) and a summary per rule.
Exit code 0 when nothing blocking is found, 1 when blocking findings exist, 2 on bad usage.
Standard library only.
"""
from __future__ import annotations

import json
import os
import re
import sys

SKIP_DIRS = {"node_modules", ".git", "dist", "build", "coverage", ".next", ".turbo", ".cache", "out"}
CODE_EXT = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte"}

# (rule id, regex, blocking?, hint)
RULES = [
    ("process-env", re.compile(r"\bprocess\.env\.(?!NODE_ENV\b)([A-Z0-9_]+)"), True,
     "Vite exposes import.meta.env.VITE_*; rename REACT_APP_* or set envPrefix"),
    ("process-env-node-env", re.compile(r"\bprocess\.env\.NODE_ENV\b"), False,
     "Vite replaces this in builds; prefer import.meta.env.MODE / DEV / PROD in app code"),
    ("require-call", re.compile(r"(?<![\w.])require\(\s*['\"]"), True,
     "Browser code must use ESM import; require() is undefined in Vite dev"),
    ("require-context", re.compile(r"\brequire\.context\s*\("), True,
     "Replace with import.meta.glob('./dir/*.ts', { eager: true })"),
    ("module-hot", re.compile(r"\bmodule\.hot\b"), True,
     "Replace with import.meta.hot (or delete: framework plugins handle HMR)"),
    ("module-exports", re.compile(r"\bmodule\.exports\b|\bexports\.[A-Za-z_]"), True,
     "Convert source files to ESM export syntax"),
    ("webpack-loader-syntax", re.compile(r"['\"][\w@/.-]*-loader!|['\"]!!?[\w@/.-]+!"), True,
     "Inline loader syntax is webpack-only; use Vite query suffixes (?raw, ?url, ?worker)"),
    ("webpack-magic-comment", re.compile(r"/\*\s*webpack(ChunkName|Prefetch|Preload|Mode)"), False,
     "Ignored by Vite; harmless, remove when convenient"),
    ("svg-react-component", re.compile(r"import\s*\{\s*ReactComponent\b[^}]*\}\s*from\s*['\"][^'\"]+\.svg['\"]"), True,
     "Use vite-plugin-svgr and import X from './x.svg?react'"),
    ("public-url", re.compile(r"%PUBLIC_URL%|process\.env\.PUBLIC_URL"), True,
     "Drop %PUBLIC_URL% (use absolute /path) and use import.meta.env.BASE_URL"),
    ("global-object", re.compile(r"(?<![\w.$])global\.(?!This)"), False,
     "Use globalThis; or define: { global: 'globalThis' } for a dependency"),
    ("node-builtin-import", re.compile(r"from\s+['\"](buffer|crypto|path|fs|stream|util|os|events)['\"]"), False,
     "webpack 4/CRA polyfilled Node built-ins; Vite does not. Replace or polyfill explicitly"),
    ("tilde-import", re.compile(r"@import\s+['\"]~"), True,
     "Remove the ~ prefix in Sass/Less imports, or add a resolve.alias for it"),
]

JSX_IN_JS = re.compile(r"<[A-Z][A-Za-z0-9.]*[\s/>]|<>|</")


def iter_files(root: str):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
        for name in filenames:
            yield os.path.join(dirpath, name)


def scan(root: str):
    findings = []
    for path in iter_files(root):
        ext = os.path.splitext(path)[1]
        rel = os.path.relpath(path, root)
        is_style = ext in {".scss", ".sass", ".less", ".css"}
        is_html = ext == ".html"
        if ext not in CODE_EXT and not is_style and not is_html:
            continue
        if os.path.basename(path).startswith(("webpack.", "craco.", "config-overrides")):
            findings.append((rel, 1, "webpack-config-file", True,
                             "Port its aliases, defines, proxy and loaders into vite.config.ts, then delete"))
            continue
        try:
            with open(path, encoding="utf-8", errors="replace") as fh:
                lines = fh.readlines()
        except OSError:
            continue
        in_config_like = rel.endswith((".config.js", ".config.cjs", "setupProxy.js")) or "/scripts/" in rel
        for i, line in enumerate(lines, 1):
            for rule, rx, blocking, hint in RULES:
                if is_style and rule != "tilde-import":
                    continue
                if is_html and rule != "public-url":
                    continue
                if rule in {"require-call", "module-exports"} and in_config_like:
                    continue  # Node-side files may stay CommonJS
                if rx.search(line):
                    findings.append((rel, i, rule, blocking, hint))
        if ext == ".js" and any(JSX_IN_JS.search(l) for l in lines if not l.lstrip().startswith("//")):
            findings.append((rel, 1, "jsx-in-js", True,
                             "Rename to .jsx (Vite's React plugin only parses JSX in .jsx/.tsx by default)"))
    return findings


def main(argv: list[str]) -> int:
    args = [a for a in argv if not a.startswith("--")]
    if len(args) != 1 or not os.path.isdir(args[0]):
        print(__doc__.strip(), file=sys.stderr)
        return 2
    root = os.path.abspath(args[0])
    findings = scan(root)
    if "--json" in argv:
        print(json.dumps([{"file": f, "line": l, "rule": r, "blocking": b, "hint": h}
                          for f, l, r, b, h in findings], indent=2))
    else:
        for f, l, r, b, h in findings:
            print(f"{f}:{l}  {'BLOCK' if b else 'warn '}  {r}  -> {h}")
        counts: dict[str, int] = {}
        for _, _, r, _, _ in findings:
            counts[r] = counts.get(r, 0) + 1
        print("\nSummary:")
        if not counts:
            print("  no webpack-specific patterns found")
        for r, n in sorted(counts.items(), key=lambda kv: -kv[1]):
            print(f"  {r:24} {n}")
    return 1 if any(b for _, _, _, b, _ in findings) else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem