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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- webpack-to-vite Installs to ~/.agents/skills/webpack-to-vite/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install webpack-to-vite The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project webpack-to-vite Installs to .agents/skills/webpack-to-vite/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/webpack-to-vite.zipunzip -o webpack-to-vite.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) webpack-to-vite PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- The project is already on Vite and only the config needs work: use vite-config
- You are moving a Next.js app between routers rather than bundlers: use nextjs-pages-to-app-migration
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.
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
- Read
AGENTS.md,package.jsonscripts, the CI workflow and the deploy config (Dockerfile, Netlify/Vercel settings, nginx) to learn wherebuild/ordist/is consumed. - 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. - 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-reactor@vitejs/plugin-vue),vitest,jsdomif tests need it - Move
public/index.htmlto the project root and add the module entry - Write
vite.config.tsthat 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 / CRA | Vite |
|---|---|
process.env.REACT_APP_X | import.meta.env.REACT_APP_X (with envPrefix) then rename to VITE_X |
process.env.NODE_ENV === 'production' | import.meta.env.PROD |
process.env.PUBLIC_URL | import.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 files | rename 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-loader | import 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)
| webpack | Vite equivalent |
|---|---|
babel-loader + preset-react/typescript | Framework 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-loader | Install sass-embedded (or sass) / less; no loader config |
file-loader / url-loader | Native asset imports; build.assetsInlineLimit (default 4 KiB) |
DefinePlugin | define (JSON-encode values) or env vars |
ProvidePlugin ($, React) | Explicit imports; automatic JSX runtime removes the need for React in scope |
HtmlWebpackPlugin | Root index.html; multiple pages via build.rollupOptions.input (rolldownOptions on Vite 8) |
CopyWebpackPlugin | public/, or vite-plugin-static-copy |
splitChunks | Dynamic 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 browsers | build.target; @vitejs/plugin-legacy if you truly need non-ESM browsers |
Tests: Jest to Vitest
jest.fn()tovi.fn(),jest.mock()tovi.mock()(hoisted the same way),jest.useFakeTimers()tovi.useFakeTimers(),jest.requireActualtoawait vi.importActual.@testing-library/jest-dom: import@testing-library/jest-dom/vitestin the setup file.- CRA’s
moduleNameMapperfor 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
undefinedat module init under ESM ordering. Fix the cycle; do not reorder imports until it works. - Case-insensitive file systems hide
import './Button'vsbutton.jsxmismatches that break on Linux CI. Build in CI before merging. - Code that reads
process.envat runtime in the browser was always a webpack fiction. If the app needs runtime config per environment, fetch it instead of rebuilding. homepageinpackage.jsonset CRA’s public path; in Vite it isbase.- Service workers from the CRA PWA template need
vite-plugin-pwa; do not ship a staleservice-worker.jsthat 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.yaml
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.py
#!/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.