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.

Replacing babel-loader and Terser with esbuild in CRA A before-and-after of the CRA webpack pipeline, swapping the babel-loader transpile rule and the Terser minimizer for esbuild-loader and ESBuildMinifyPlugin. CRACO override: two rules, one minimizer Before (babel) babel-loader preset-react + plugins TerserPlugin minify (single thread) ~38s production build swap After (esbuild) esbuild-loader loader: 'tsx', target ESBuildMinifyPlugin parallel Go minify ~6s production build Lost in the swap: Babel macros, custom Babel plugins emotion/styled-components labels need their own esbuild-compatible setup esbuild does not type-check — keep tsc --noEmit in CI
Figure: the CRACO override replaces the babel transpile rule and the Terser minimizer, trading Babel plugin support for esbuild's parallel speed.

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:

  1. Time the baseline build: time npm run build (pre-CRACO) and note the wall-clock seconds. babel-loader + TerserPlugin typically dominates 70%+ of that.
  2. Profile the loaders: run npx react-scripts build --stats then inspect build/bundle-stats.json, or set SpeedMeasurePlugin temporarily to attribute time to babel-loader vs terser.
  3. Locate the rules to replace: in an ejected build, search config/webpack.config.js for loader: require.resolve('babel-loader') (there are two — app code and node_modules) and new TerserPlugin. Under CRACO you patch these in code rather than by hand.
  4. Inventory Babel-specific features: grep for babel-plugin-macros, .macro imports, babel.config.js plugins, and babel-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' with jsxFactory/jsxFragment for a classic React.createElement setup.
  • 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 *.macro or relying on babel-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 / .babelrc plugin (decorators with legacy semantics, babel-plugin-transform-imports, etc.) is not honored. esbuild supports standard decorators via tsconfig experimentalDecorators, but bespoke plugins need a hand-written esbuild plugin or removal.
  • emotion and styled-components lose their Babel plugin. Without @emotion/babel-plugin or babel-plugin-styled-components, you lose component displayName labels, the css prop’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, set displayName manually 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 --noEmit to npm run build (e.g. "build": "tsc --noEmit && craco build") if you want the old fail-fast behavior.