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.
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.
# 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. Keep your existing tsconfig.json — esbuild reads target, jsx, and paths from it but ignores type-level options.
Diagnosis workflow
Confirm the babel rule is actually the bottleneck before swapping. Measure first:
- Time the baseline build:
time npm run build(pre-CRACO) and note the wall-clock seconds. babel-loader + TerserPlugin typically dominates 70%+ of that. - Profile the loaders: run
npx react-scripts build --statsthen inspectbuild/bundle-stats.json, or setSpeedMeasurePlugintemporarily to attribute time tobabel-loadervsterser. - Locate the rules to replace: in an ejected build, search
config/webpack.config.jsforloader: require.resolve('babel-loader')(there are two — app code and node_modules) andnew TerserPlugin. Under CRACO you patch these in code rather than by hand. - Inventory Babel-specific features: grep for
babel-plugin-macros,.macroimports,babel.config.jsplugins, andbabel-plugin-styled-components/ emotion’s@emotion/babel-plugin. 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.
// 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;
},
},
};
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. Drop to'es2015'only if you must support older browsers — esbuild will not polyfill, only lower syntax it supports.jsx: 'automatic'matches the React 17+ JSX transform CRA uses by default; use'transform'withjsxFactory/jsxFragmentfor a classicReact.createElementsetup.loader: 'tsx'tells esbuild to parse TS + JSX together;'ts'rejects JSX,'jsx'rejects type syntax.
Verification
After wiring CRACO, prove correctness and speed:
# 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
- 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, switch to the runtime JSX pragma (@jsxImportSource @emotion/react) and accept slightly larger output; for styled-components, setdisplayNamemanually or accept generated names. Verify your styles render before shipping. - 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.
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.