Skip to main content

⚙️ Configuring Babel

You know what transpilation does. Now you'll tell Babel exactly how to do it for your project — which config file to use, how to wire up @babel/preset-env, how to pick a polyfill strategy, and how to give development, production, and test builds their own settings. This is the file every real JavaScript build reads, so it pays to understand each line.

Week 3 · Day 4 (Thursday: Babel and Transpilation) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Choose between babel.config.js, .babelrc, and the package.json key, and explain when each applies
  • Configure @babel/preset-env with targets, useBuiltIns, and corejs
  • Pick the right polyfill strategy: usage vs entry vs false
  • Write environment-specific config for development, production, and test
  • Integrate Babel with a bundler through babel-loader and enable caching
  • Debug a config with the debug flag and npx browserslist

Estimated Time: 60 minutes

Practice: Write a working babel.config.js for a browser app and a second one for a Node service.

In This Lesson

Installing Babel

Think of Babel like a stand mixer with attachments. The core is the motor, the CLI is the control panel, and presets are the attachments that actually do the work. You install them as dev dependencies because transpilation happens at build time, not in production.

# The Babel engine and command-line runner
npm install --save-dev @babel/core @babel/cli

# The "smart" preset that knows what modern JS to transform
npm install --save-dev @babel/preset-env

# core-js supplies the actual polyfills (a runtime dependency)
npm install --save core-js

With these in place, you can transpile a file from the terminal to confirm everything works:

# Transpile src/ into a lib/ folder
npx babel src --out-dir lib

💡 dev dependency vs runtime dependency

@babel/* packages are --save-dev: they run on your machine/CI to produce output, and your users never download them. core-js is a plain dependency because its polyfill code gets bundled into the shipped app so old browsers can run it.

Choosing a Config File

Babel reads its settings from a config file in your project. There are three formats, and the choice mostly comes down to how far the config should reach.

graph TD A[Which config file?] --> B{Project shape?} B -->|Monorepo or need to
compile node_modules| C[babel.config.js
project-wide root config] B -->|Single package| D{Config complexity?} D -->|Programmatic / conditional| E[babel.config.js] D -->|Simple & declarative| F[.babelrc / .babelrc.json
or package.json key]

1. babel.config.js — project-wide (recommended default)

Applies to the whole project from the root, including files in node_modules if you ask. It can export a function for conditional logic. This is the modern recommendation for almost everything.

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: 'defaults', // resolves via Browserslist
      useBuiltIns: 'usage',
      corejs: 3
    }]
  ]
};

2. .babelrc.json — file-relative

Applies only to files in its own directory subtree. Good for simple single-package setups, but it does not reach into node_modules.

// .babelrc.json
{
  "presets": ["@babel/preset-env"]
}

3. The babel key in package.json

Same behavior as .babelrc, just co-located with your other project metadata. Handy when you want one less file.

// package.json
{
  "name": "my-project",
  "babel": {
    "presets": ["@babel/preset-env"]
  }
}

✅ Rule of thumb

Reach for babel.config.js first. It's the only format that reliably applies project-wide (important for monorepos and for compiling dependencies), and its function form unlocks the environment logic you'll use below. Drop to .babelrc only for tiny, single-folder projects.

preset-env in Detail

@babel/preset-env is the workhorse. Instead of you listing dozens of individual transform plugins, you describe your targets and it selects exactly the transforms (and polyfills) those targets need. Here are the options you'll actually reach for:

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      // 1. WHO must run this code
      targets: '> 0.5%, last 2 versions, not dead',

      // 2. HOW to handle polyfills (see next section)
      useBuiltIns: 'usage',
      corejs: 3,

      // 3. Keep ES module syntax so the bundler can tree-shake.
      //    Set to 'commonjs' only for environments that need require().
      modules: false,

      // 4. Prints exactly which transforms/polyfills were chosen —
      //    invaluable while learning. Turn off in normal builds.
      debug: false
    }]
  ]
};
OptionWhat it controlsTypical value
targetsWhich browsers/Node versions to supportA Browserslist query, or { node: 'current' }
useBuiltInsPolyfill strategy'usage'
corejscore-js major version for polyfills3
modulesWhether to convert ES modulesfalse (let the bundler handle them)
debugLog chosen transforms & targetstrue while diagnosing

💡 targets can come from Browserslist instead

If you omit targets here, preset-env reads your browserslist config from package.json or .browserslistrc. Keeping targets in Browserslist is preferred because Autoprefixer and other tools read the same source of truth — one list, consistent behavior everywhere.

Polyfill Strategies

Recall from the last lesson: transforms fix syntax, but missing APIs (like Promise or Array.includes) need polyfills from core-js. The useBuiltIns option decides how those polyfills get in.

graph TD A[useBuiltIns] --> B["'usage' (recommended)"] A --> C["'entry'"] A --> D["false"] B --> B1[Babel auto-imports only the
polyfills each file actually uses] C --> C1[You import core-js once at the
entry; Babel prunes to your targets] D --> D1[No automatic polyfills —
you manage them by hand]

1. usage — automatic and lean (recommended)

Babel scans each file and injects only the polyfills that file needs for your targets. No manual imports, smallest sensible bundle.

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      useBuiltIns: 'usage',
      corejs: 3
    }]
  ]
};

// your code — no core-js imports needed:
const ok = [1, 2, 3].includes(2);
const p = Promise.resolve();
// Babel auto-prepends the matching core-js modules for old targets.

2. entry — one import, pruned to targets

You import core-js once at your app's entry point; Babel then replaces that single import with exactly the polyfills your targets require. Useful when third-party code might use APIs your own source never references directly.

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', { useBuiltIns: 'entry', corejs: 3 }]
  ]
};

// src/index.js — the FIRST lines of your app:
import 'core-js/stable';
// ...rest of your app

3. false — you're on your own

Babel adds no polyfills. You import exactly what you need by hand. Maximum control, maximum responsibility — rarely the right default.

// babel.config.js
module.exports = {
  presets: [['@babel/preset-env', { useBuiltIns: false }]]
};

// you must import each polyfill yourself:
import 'core-js/features/array/includes';
import 'core-js/features/promise';

⚠️ Always pin corejs

Whenever useBuiltIns is 'usage' or 'entry', you must set corejs: 3 (matching your installed core-js major version) or Babel warns and behaves unexpectedly. For libraries you publish, prefer @babel/plugin-transform-runtime instead so you don't pollute the global scope of whoever consumes your package.

Per-Environment Config

Your builds have different jobs. Development wants fast rebuilds and readable output; production wants small, optimized bundles; test runs in Node and wants CommonJS so the test runner can require() modules. Babel's function form plus api.env() lets one file serve all three.

// babel.config.js
module.exports = function (api) {
  const isDev  = api.env('development');
  const isProd = api.env('production');
  const isTest = api.env('test');

  // Cache the resolved config per NODE_ENV for faster rebuilds
  api.cache.using(() => process.env.NODE_ENV);

  return {
    presets: [
      ['@babel/preset-env', {
        // Tests run in Node, so target the current Node version;
        // everything else targets browsers via Browserslist.
        targets: isTest ? { node: 'current' } : undefined,

        // Jest needs CommonJS; the browser build keeps ES modules
        // so the bundler can tree-shake.
        modules: isTest ? 'commonjs' : false,

        useBuiltIns: 'usage',
        corejs: 3,
        debug: isDev
      }]
    ],
    plugins: [
      // Only strip console.* calls in production bundles
      isProd && ['transform-remove-console', { exclude: ['error', 'warn'] }]
    ].filter(Boolean),

    // Inline source maps in dev for easy debugging
    sourceMaps: isDev ? 'inline' : false
  };
};

💡 How api.env() knows the environment

It reads process.env.BABEL_ENV, falling back to process.env.NODE_ENV, defaulting to "development". Your tooling sets these: Jest sets NODE_ENV=test, a production build script sets NODE_ENV=production. The .filter(Boolean) trick drops any false entries so conditional plugins cleanly disappear when not wanted.

Wiring Into a Bundler

On its own, Babel transpiles files. In a real app, your bundler (Webpack, Vite, Rollup...) calls Babel on each JavaScript module as it builds the dependency graph. With Webpack that bridge is babel-loader.

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /node_modules/,   // don't transpile deps — slow & usually needless
        use: {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true   // reuse results between builds = much faster
          }
        }
      }
    ]
  }
};

Notice the loader has no presets listed — it reads your project's babel.config.js automatically. Keep the config in one place; don't duplicate it inside the loader.

📖 A note on newer bundlers

Tools like Vite and esbuild can transpile syntax themselves (via esbuild/SWC) and often skip Babel for speed. You'll still reach for Babel when you need its rich plugin ecosystem, precise preset-env + core-js polyfilling, or a macro/transform no other tool provides.

Debugging Your Config

When Babel isn't doing what you expect, three moves solve most problems.

1. Turn on debug

Set debug: true on preset-env and run a build. Babel prints the resolved targets and every transform/polyfill it applied — so you can confirm your intent matched reality.

@babel/preset-env: `DEBUG` option

Using targets: { "chrome": "58", "ie": "11" }
Using modules transform: false
Using plugins:
  transform-template-literals { "ie":"11" }
  transform-arrow-functions   { "ie":"11" }
  transform-classes           { "ie":"11" }
Using polyfills with `usage` option:
  es.array.includes { "ie":"11" }

2. Check what your targets resolve to

Your Browserslist query resolves to a concrete list that changes as usage data updates. Print it:

npx browserslist "> 0.5%, last 2 versions, not dead"

3. Inspect the transform in isolation

Paste a snippet into the Babel REPL and toggle targets to see the exact output — far faster than rebuilding a whole project to test one idea.

Output

npx browserslist "last 2 versions, not dead"
# → chrome 126, chrome 125, firefox 128, safari 17.5, ... (varies by date)

Practice & Quiz

🏋️ Exercise 1: A browser-app config

Goal: Write a babel.config.js for a browser app that supports the last 2 versions of major browsers (excluding dead ones), auto-adds only the polyfills used, keeps ES modules for tree-shaking, and pins core-js 3.

💡 Hint

One preset entry: @babel/preset-env with targets, useBuiltIns: 'usage', corejs: 3, and modules: false.

✅ Solution
// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: 'last 2 versions, not dead',
      useBuiltIns: 'usage',
      corejs: 3,
      modules: false
    }]
  ]
};

🏋️ Exercise 2: A Node service config

Goal: Write a config for a Node.js API that targets the current Node version. Since a matching Node already supports modern syntax and APIs, you shouldn't need heavy polyfills.

✅ Solution
// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: { node: 'current' }
      // No useBuiltIns needed: current Node already has these APIs.
    }]
  ]
};

Targeting { node: 'current' } tells preset-env to transform almost nothing — the running Node understands modern JS — so builds are fast and output stays close to your source.

🎯 Quick Quiz

Question 1: Which config format applies project-wide and can compile files in node_modules?

Question 2: Which useBuiltIns value auto-adds only the polyfills each file actually uses?

Question 3: Why set modules: false for a browser build that goes through a bundler?

Best Practices & Pitfalls

✅ Do

  • Default to babel.config.js; keep browser targets in a shared Browserslist config
  • Use useBuiltIns: 'usage' with an explicit corejs: 3
  • Keep modules: false for bundled browser builds; switch to 'commonjs' only for Node/tests
  • Enable cacheDirectory: true in babel-loader and exclude node_modules
  • Reach for debug: true when output surprises you, then turn it back off

❌ Don't

  • Set useBuiltIns to 'usage'/'entry' without pinning corejs
  • Duplicate presets inside babel-loader options and again in the config file
  • Transpile dependencies by default — it's slow and usually pointless
  • Hard-code an ancient target like ie 11 unless real users still need it
  • Use useBuiltIns global polyfills in a published library — use transform-runtime instead

⚠️ The stale-cache gotcha

Caching speeds builds but can mask config changes. If edits to babel.config.js seem to have no effect, clear the loader cache (delete node_modules/.cache) or key the cache on NODE_ENV with api.cache.using() as shown above.

Summary

🎉 Key Takeaways

  • Prefer babel.config.js — it's project-wide and supports conditional logic
  • @babel/preset-env turns your targets into exactly the transforms and polyfills you need
  • useBuiltIns: 'usage' + corejs: 3 is the sensible default polyfill strategy
  • Use api.env() to give dev, prod, and test their own settings from one file
  • Let the bundler call Babel via babel-loader; enable caching and exclude node_modules

📚 Additional Resources

🚀 What's Next?

Your config now controls what gets down-leveled. Next you'll zoom out to the strategy layer: Browser Compatibility — how to decide your real targets with caniuse data, feature-detect at runtime, and combine transpilation with progressive enhancement so your app degrades gracefully.

🎉 Config mastered!

You can now stand up a Babel setup that fits a browser app, a Node service, or a test suite — and explain every option to a teammate. That's a genuinely employable tooling skill.