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.

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.

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.

CRACO patches the generated webpack config without ejecting react-scripts owns the webpack config; routing the build script through CRACO lets craco.config.js patch that config in memory, so you swap loaders without ejecting and owning the whole config file. patch in memory — never eject craco buildscript entry craco.config.jsconfigure(webpackConfig) react-scripts webpack 5generated, unowned
Figure: CRACO intercepts the config 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:

Four-step pre-swap diagnosis Time the baseline build, profile the loaders to attribute time to babel-loader versus Terser, locate the two babel-loader rules and the TerserPlugin, then inventory Babel-specific features like macros and plugins that will not survive the swap. 1 time baselinewall-clock 2 profile loadersbabel vs terser 3 locate rules2× loader + Terser 4 inventory Babelmacros / plugins
Figure: step 4 is the one that bites later — anything Babel-specific is a no-op once the loader is gone.
  1. 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.
  2. Profile the loaders: run npx react-scripts build --stats and inspect build/bundle-stats.json, or wrap the config with SpeedMeasurePlugin temporarily to attribute time to babel-loader versus terser per 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.
  3. Locate the rules to replace: in an ejected build, search config/webpack.config.js for loader: require.resolve('babel-loader'). There are two matches, and the distinction matters: the first has include: paths.appSrc and transpiles your application code with the full preset stack; the second targets node_modules and applies only a thin “dependencies” preset to down-level published packages. The override removes both. Also find new TerserPlugin in optimization.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 the node_modules babel rule in place means half your bundle is still going through Babel.
  4. Inventory Babel-specific features: grep for babel-plugin-macros, .macro imports, babel.config.js / .babelrc plugins, and babel-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.

Three edits, three rules left untouched The override adds esbuild-loader after babel-loader and removes both babel rules, sets loader tsx on the TypeScript rule, and replaces the minimizer with EsbuildPlugin; the CSS and asset rules are deliberately left unchanged. change three things — leave the rest of the config alone 1 loader ruleesbuild-loader in, babel out 2 loader: 'tsx'parse TS + JSX 3 minimizerEsbuildPlugin untouched: CSS rules · asset/file rules · resolve config
Figure: the swap is surgical — three rule edits, everything else in the CRA config survives intact.
// 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 like Promise, Array.prototype.flat, or structuredClone. CRA’s Babel path did not polyfill those either (it relied on your core-js imports or react-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 from react/jsx-runtime so components no longer need import React. Use 'transform' with jsxFactory/jsxFragment only for a classic React.createElement setup; mixing the two (automatic runtime but files that still import React from 'react' and use React.createElement directly) 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 .tsx file, 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 .tsx sources without complaint.

Verification

After wiring CRACO, prove correctness and speed:

Three-way verification after the swap Run tsc noEmit separately because esbuild does not type-check, time the new build against the baseline expecting single-digit seconds, and serve the output to confirm it still boots and renders within a few percent of the Terser bundle size. tsc --noEmitthe only type gate time npm run buildtens of s → single digits serve -s buildsize within a few % a big size regression means target too high or css: true omitted
Figure: speed is the headline, but the 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

Four things lost when babel-loader leaves Babel macros become no-ops, custom Babel plugins are dropped, emotion and styled-components lose their compile-time Babel plugin, and no type errors fail the build anymore so tsc noEmit must be wired into the build script. Babel macros → no-op.macro imports, preval, graphql.macro custom Babel plugins dropped.babelrc / babel.config.js emotion / styled-componentsno displayName, use JSX pragma type errors no longer fail→ tsc --noEmit && craco build
Figure: three losses to plan around plus one guardrail to add — wiring tsc --noEmit back into the build.
  • 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 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 displayName manually or accept the generated hashes; there is no esbuild equivalent to its ssr/displayName transform. 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 --noEmit to npm 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.