Replacing babel-loader with esbuild in a CRA Project
This guide replaces babel-loader with esbuild-loader in a Create-React-App build — via CRACO so you avoid a full eject — to cut transpilation and minification time by roughly 5–10x on a mid-sized app. It is the webpack-side counterpart to Integrating esbuild with Framework Toolchains, which covers the Vite, tsup, and Rollup pipelines; here the host is webpack 5 underneath react-scripts, and the swap is two loader rules plus the minimizer.
The problem this solves is structural, not incidental. Babel is a JavaScript program parsing and re-emitting JavaScript, running on a single V8 thread per worker, walking a full AST for every module and applying every preset and plugin transform in sequence. On a CRA app of any size that turns into the dominant cost of both react-scripts start (the first cold compile and every subsequent recompile) and react-scripts build (transpile plus a single-threaded Terser pass over the whole bundle). esbuild does the same syntactic work — strip TypeScript types, lower JSX to function calls, lower modern syntax to your target — in Go, across all CPU cores, with a parser an order of magnitude faster than Babel’s. The gap is not a micro-optimization; it is the difference between a 40-second production build and a 6-second one, and between a 3-second and a sub-second incremental recompile in the dev server.
The catch is that esbuild is a transpiler and minifier, not a plugin host. Babel’s value in a CRA project is rarely just “turn JSX into JS” — it is the accumulated pile of macros, code-generating plugins, and CSS-in-JS compile-time transforms that ride along in the same pass. esbuild has no equivalent to babel-plugin-macros, no plugin API at the per-file transform level that a loader can reach, and — critically — it does no type-checking. So this swap is a trade: you buy a large, permanent reduction in build time and you sell Babel’s extensibility and the incidental type safety teams lean on. The rest of this guide is about making that trade deliberately: measuring first, isolating exactly the two rules and one plugin that need to change, and re-establishing the guardrails (type-checking, CSS-in-JS labels) that the old path gave you for free.
Prerequisites & reproducible setup
This assumes a standard create-react-app (react-scripts 5.x, which already runs webpack 5) and that you are not going to eject. CRACO lets you patch the generated webpack config without owning it. If you have already ejected, apply the same edits directly to config/webpack.config.js.
The reason CRACO is the right tool rather than a .env flag or a fork is that react-scripts deliberately hides its webpack config. Ejecting copies that config — thousands of lines across config/ and scripts/ — into your repo permanently, which means every future react-scripts patch release is now yours to merge by hand. CRACO instead requires the built config in memory at build time and hands you a mutation hook, so you keep taking upstream fixes to the base config while overriding only the two nodes you care about. The cost is coupling to CRACO’s knowledge of the CRA config shape: a major react-scripts restructure can break CRACO’s matchers, which is why the override below uses CRACO’s own getLoaders/loaderByName helpers rather than hard-coded array indices into module.rules.
# react-scripts 5.x, Node 20+
npx create-react-app my-app --template typescript
cd my-app
npm install --save-dev @craco/craco@7.1.0 esbuild-loader@4.2.2
Then route the CRA scripts through CRACO in package.json:
// package.json — swap react-scripts for craco on build-affecting scripts
{
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test"
}
}
esbuild-loader@4.2.2 bundles esbuild 0.23+ internally; check npm ls esbuild so a hoisted older copy does not win. A duplicate esbuild in the tree is not cosmetic: esbuild ships a native Go binary per platform, and a version skew between the JS shim and the binary throws "Cannot start service: Host version does not match binary version" at build time. If npm ls esbuild shows two versions, add an npm overrides (or Yarn resolutions) entry pinning esbuild to the version esbuild-loader expects, then reinstall. Keep your existing tsconfig.json — esbuild reads target, jsx, jsxImportSource, and paths from it but ignores every type-level option (strict, noUnusedLocals, and the rest), because it never builds a type graph. That last point is the whole reason the Verification section insists on a separate tsc --noEmit: nothing in the new pipeline reads those options.
react-scripts generates, so the loader swap lives in one file you own.Diagnosis workflow
Confirm the babel rule is actually the bottleneck before swapping. This matters because build slowness in CRA has more than one cause: source-map generation, the fork-ts-checker type-check worker, postcss on large stylesheets, and asset copying all show up in wall-clock time, and none of them are fixed by swapping the transpiler. If Babel and Terser are only 30% of your build, this change buys you a 30% ceiling and nothing more. Measure first:
- Time the baseline build: run
time npm run build(pre-CRACO) three times and take the median — the first run is cold and misleading because webpack’s persistent filesystem cache is empty. Note the wall-clock seconds; on a typical mid-sized CRA app, babel-loader plus the single-threaded TerserPlugin dominate 70%+ of that total, with Terser alone often the single largest line item because it re-parses and re-serializes the entire concatenated bundle on one thread. - Profile the loaders: run
npx react-scripts build --statsand inspectbuild/bundle-stats.json, or wrap the config withSpeedMeasurePlugintemporarily to attribute time tobabel-loaderversusterserper rule. Read the numbers as a share of total, not in isolation: a loader that takes 12 seconds is only worth attacking if the whole build is 20, not 200. SpeedMeasurePlugin is intrusive and can conflict with some plugins, so treat it as a throwaway diagnostic — remove it before shipping the CRACO config. - Locate the rules to replace: in an ejected build, search
config/webpack.config.jsforloader: require.resolve('babel-loader'). There are two matches, and the distinction matters: the first hasinclude: paths.appSrcand transpiles your application code with the full preset stack; the second targetsnode_modulesand applies only a thin “dependencies” preset to down-level published packages. The override removes both. Also findnew TerserPlugininoptimization.minimizer. Under CRACO you patch these in code rather than by hand, so you never touch the file directly — but you need to know both rules exist, because leaving thenode_modulesbabel rule in place means half your bundle is still going through Babel. - Inventory Babel-specific features: grep for
babel-plugin-macros,.macroimports,babel.config.js/.babelrcplugins, andbabel-plugin-styled-components/ emotion’s@emotion/babel-plugin. Each of these is a compile-time transform that lives inside the Babel pass you are about to delete, so each becomes a silent no-op the moment babel-loader leaves — not an error, just missing output. This is the step that bites weeks later when someone notices class names regressed or a macro import now ships raw to the browser, so do it now and write the list down. These do not survive the swap and need handling (see Gotchas).
The complete CRACO override
This is the entire craco.config.js. It replaces the app babel-loader rule with esbuild-loader, swaps the Terser minimizer for esbuild’s, and leaves CSS/asset rules untouched. The ordering inside configure is deliberate: locate and remove the loader rules first, then patch the loader options, then replace the minimizer, all before returning the mutated config object to CRACO, which hands it to webpack. Because configure mutates the config in place and returns it, an early return or a thrown error leaves you with a half-patched config, so keep the three steps in one straight-line function.
// craco.config.js — @craco/craco 7.1.0, esbuild-loader 4.2.2, react-scripts 5.x, Node 20+
const { addAfterLoader, removeLoaders, loaderByName, getLoaders } = require('@craco/craco');
module.exports = {
webpack: {
configure(webpackConfig) {
// --- 1. Replace babel-loader for app JS/TS/JSX/TSX with esbuild-loader ---
const { hasFoundAny, matches } = getLoaders(
webpackConfig,
loaderByName('babel-loader')
);
if (hasFoundAny) {
// The first babel-loader match is the app-code rule (include: src).
addAfterLoader(webpackConfig, loaderByName('babel-loader'), {
loader: require.resolve('esbuild-loader'),
options: {
target: 'es2020', // browser floor; matches your browserslist intent
// .js files in CRA may contain JSX, so enable the automatic runtime:
jsx: 'automatic', // React 17+ automatic JSX; use 'transform' for classic
},
});
// Remove BOTH babel-loader rules (app + node_modules transpile).
removeLoaders(webpackConfig, loaderByName('babel-loader'));
}
// --- 2. Make esbuild-loader handle .ts/.tsx explicitly ---
// CRA's default test already covers tsx; ensure the loader knows the syntax.
webpackConfig.module.rules.forEach((rule) => {
if (!rule.oneOf) return;
rule.oneOf.forEach((one) => {
if (
one.loader &&
one.loader.includes('esbuild-loader') &&
one.test &&
one.test.toString().includes('tsx')
) {
one.options = { ...one.options, loader: 'tsx' };
}
});
});
// --- 3. Replace Terser with esbuild's minifier ---
const { EsbuildPlugin } = require('esbuild-loader');
webpackConfig.optimization.minimizer = [
new EsbuildPlugin({
target: 'es2020', // keep in lockstep with the loader target
css: true, // also minify CSS (replaces css-minimizer where desired)
legalComments: 'none',
}),
];
return webpackConfig;
},
},
};
A few mechanics of that override are worth stating explicitly, because they are the parts that break when the config shape shifts. getLoaders walks every module.rules[].oneOf[] entry and returns matches for the predicate, so hasFoundAny is your guard against a CRA version where the babel rule moved or was renamed — if it is false, you should fail loudly rather than silently ship an un-swapped build. addAfterLoader inserts the esbuild rule immediately after the first babel match so it inherits the same test/include position in the oneOf chain (webpack tries oneOf entries top-down and stops at the first match, so position is load-bearing). removeLoaders then strips both babel entries, leaving esbuild as the sole transpile rule for JS/TS. The second block re-finds the esbuild rule and forces loader: 'tsx' because CRA’s TypeScript test covers .tsx, and esbuild must be told the source dialect per rule — it does not infer JSX from the file extension the way it does from a tsconfig.
In esbuild-loader@4.x the minify plugin is exported as EsbuildPlugin (the older ESBuildMinifyPlugin name was removed in v3). If you are pinned to esbuild-loader@2.x, the import is instead:
// esbuild-loader 2.x ONLY — legacy minimizer name
const { ESBuildMinifyPlugin } = require('esbuild-loader');
webpackConfig.optimization.minimizer = [
new ESBuildMinifyPlugin({ target: 'es2020', css: true }),
];
Per-file loader settings that matter
target: 'es2020'controls syntax lowering — which language features esbuild rewrites into older equivalents (optional chaining, nullish coalescing, class fields, async/await). It is not a polyfill switch: esbuild lowers syntax it understands but never injects runtime shims for APIs likePromise,Array.prototype.flat, orstructuredClone. CRA’s Babel path did not polyfill those either (it relied on yourcore-jsimports orreact-app-polyfill), so keep whichever polyfill entry you already had. Drop to'es2015'only if you must support pre-2020 browsers, and expect slightly larger output because more constructs get down-leveled.jsx: 'automatic'matches the React 17+ JSX transform CRA uses by default, emitting imports fromreact/jsx-runtimeso components no longer needimport React. Use'transform'withjsxFactory/jsxFragmentonly for a classicReact.createElementsetup; mixing the two (automatic runtime but files that stillimport React from 'react'and useReact.createElementdirectly) is fine — the stray import is harmless.loader: 'tsx'tells esbuild to parse TS type syntax and JSX together in one grammar;'ts'rejects JSX in a.tsxfile, and'jsx'rejects type annotations. When in doubt,'tsx'is the safe superset for a React + TypeScript CRA app because it accepts plain.js,.jsx, and.tsxsources without complaint.
Verification
After wiring CRACO, prove correctness and speed:
tsc --noEmit gate is what replaces the safety the old path never really had.# 1. Type-check separately — esbuild does NOT do this.
npx tsc --noEmit
# 2. Time the new build and compare to the baseline.
time npm run build
# 3. Confirm output still boots and renders.
npx serve -s build
Expected: the production build drops from tens of seconds to single digits on a mid-sized app, and build/static/js/*.js shrinks or stays within a few percent of the Terser output. A meaningful size regression usually means target is set too high (less lowering) or css: true was omitted. Wire tsc --noEmit into CI as a required check, because the loader swap silently removes the only type-checking the build used to (indirectly) enforce.
Gotchas & edge cases
tsc --noEmit back into the build.-
Babel macros stop working. Anything importing
*.macroor relying onbabel-plugin-macros(e.g.tailwind.macro,graphql.macro,preval.macro) is a no-op once babel-loader is gone — esbuild has no macro mechanism. Migrate those to runtime equivalents or a pre-build script before swapping. -
Custom Babel plugins are dropped. Any
babel.config.js/.babelrcplugin (decorators with legacy semantics,babel-plugin-transform-imports, etc.) is not honored. esbuild supports standard decorators viatsconfigexperimentalDecorators, but bespoke plugins need a hand-written esbuild plugin or removal. -
emotion and styled-components lose their Babel plugin. Without
@emotion/babel-pluginorbabel-plugin-styled-components, you lose componentdisplayNamelabels, thecssprop’s compile-time transform (emotion), and SSR-friendly class names. For emotion the fix is to stop relying on the Babel-injected pragma and drive it from esbuild’s own JSX options, either per file or globally in the loader:// craco.config.js addition — route emotion's css prop through esbuild's JSX, no Babel plugin // esbuild-loader 4.2.2: set the import source so the css prop compiles without @emotion/babel-plugin options: { target: 'es2020', jsx: 'automatic', jsxImportSource: '@emotion/react', // was @jsxImportSource pragma injected by the Babel plugin },Accept slightly larger output and less readable class names, since the source-location labels the Babel plugin added are gone. For styled-components, set
displayNamemanually or accept the generated hashes; there is no esbuild equivalent to itsssr/displayNametransform. Verify your styles render before shipping — a missing pragma fails as unstyled components, not as a build error. -
No type errors fail the build anymore. CRA’s babel path never type-checked either, but teams often rely on the build surfacing JSX/import mistakes. esbuild only reports syntax errors, so add
tsc --noEmittonpm run build(e.g."build": "tsc --noEmit && craco build") if you want the old fail-fast behavior.
When not to make this swap
The swap is not free of downside, and a few situations make it a net loss. If your app leans on babel-plugin-macros for anything load-bearing — graphql.macro compiling queries at build time, preval.macro inlining computed constants, a design-system’s styled macro — you are trading a fast build for a broken one, and rewriting those to runtime equivalents may cost more than the build time you save. If you depend on legacy-semantics decorators or a bespoke Babel plugin that rewrites your code in ways esbuild’s standard transforms cannot reproduce, the same logic applies: esbuild supports TC39-standard and experimentalDecorators decorators, but not arbitrary AST rewrites. And if profiling in the Diagnosis step showed Babel and Terser are a minority of your build time — because fork-ts-checker, PostCSS, or asset handling dominate — the swap changes a number that was never the bottleneck. In that case, attack the real cost first: disable the type-check worker in dev, prune PostCSS plugins, or split the build. The honest test is the one from Diagnosis: if the two babel rules plus Terser are not a clear majority of wall-clock time, this change is not the win you are looking for.
Related
- Integrating esbuild with Framework Toolchains — the broader guide on slotting esbuild into Vite, tsup, Remix, and Angular pipelines.
- esbuild API and CLI for Rapid Builds — the underlying
transform/buildoptions thatesbuild-loaderforwards. - Using esbuild transform API for TypeScript stripping — how the per-file TS/JSX stripping that the loader performs actually works.