Babel Setup for Legacy Browser Support: Presets, Plugins, Polyfills and Bundler Integration
Key takeaways
What Babel actually changes in your code, how preset-env decides from browserslist targets, why syntax transforms and polyfills are separate problems, how apps and libraries should configure core-js differently, and what breaks when moving to Babel 8.
What Babel does, and what it does not
Babel is a source-to-source compiler for JavaScript. It parses your code into an abstract syntax tree, lets plugins rewrite that tree, and prints JavaScript back out. Out of the box it does nothing: every transformation (arrow functions to functions, JSX to function calls, stripping TypeScript types) is a plugin, and presets are just curated lists of plugins.
Two limits shape every Babel configuration:
- Babel rewrites syntax, not missing APIs. It can turn
a?.borclass { #x }into older syntax, because those are grammar. It cannot makeArray.prototype.findLastorPromise.withResolversexist in an old browser, because those are runtime objects. That second problem is solved by polyfills (in practice,core-js), which Babel can insert imports for but does not implement. - Babel works one file at a time and knows nothing about types. It strips TypeScript annotations without checking them, and it cannot resolve anything that requires looking at other files.
This post shows how to configure Babel for an app that must support older browsers, how the polyfill options differ, how a library should be set up differently from an app, and what changed in Babel 8.
Installation and a minimal config
npm install -D @babel/core @babel/cli @babel/preset-env
// babel.config.json
{
"presets": ["@babel/preset-env"]
}
npx babel src --out-dir dist
Preset names must be JSON strings. A config such as {"presets": [@babel/preset-env]} is a common copy-paste error, and Babel reports it at load time:
ConfigError: Error while parsing config - JSON5: invalid character '@' at 1:13
babel.config.json vs .babelrc. babel.config.json (or .js) at the project root is a project-wide config that also applies to files in node_modules if your bundler sends them through Babel. .babelrc is file-relative and only applies within its own package. In monorepos, or when you need to transpile a dependency, .babelrc silently not applying is a frequent source of “Babel ignored my config” confusion; prefer babel.config.json.
preset-env and browser targets
@babel/preset-env includes the plugins for every standardized syntax feature and enables only the ones your targets need. The targets usually come from browserslist:
# .browserslistrc
> 0.5%
last 2 versions
not dead
or the equivalent "browserslist" array in package.json. Run npx browserslist to print the exact browser versions a query resolves to. Queries like > 0.5% depend on usage data bundled in the caniuse-lite package, so the resolved list changes when that package updates; running npx update-browserslist-db@latest refreshes it, and the warning Browserslist: caniuse-lite is outdated is a reminder to do so.
The targets make a very visible difference. The same input compiled for Internet Explorer 11 and for Chrome 100:
// input
const f = [1].findLast(x => x);
const s = 'a'.replaceAll('a', 'b');
const p = Promise.withResolvers();
// targets: "ie 11" (Babel 7, useBuiltIns: "usage", corejs: "3.40")
"use strict";
require("core-js/modules/es.array.find-last.js");
require("core-js/modules/es.object.to-string.js");
require("core-js/modules/es.promise.js");
require("core-js/modules/es.promise.with-resolvers.js");
require("core-js/modules/es.regexp.exec.js");
require("core-js/modules/es.string.replace.js");
require("core-js/modules/es.string.replace-all.js");
var f = [1].findLast(function (x) {
return x;
});
var s = "a".replaceAll("a", "b");
var p = Promise.withResolvers();
// targets: "chrome 100"
"use strict";
require("core-js/modules/es.promise.with-resolvers.js");
const f = [1].findLast(x => x);
const s = "a".replaceAll("a", "b");
const p = Promise.withResolvers();
For IE 11 the arrow function became a function and seven polyfill modules were imported. For Chrome 100 the syntax is untouched and only Promise.withResolvers needs a polyfill, because findLast and replaceAll already exist there. Every browser you drop from the targets removes code from the output, which is why reviewing the browserslist query is the most effective bundle-size change in many Babel setups.
Notice also that @babel/cli turned the output into CommonJS ("use strict" and require). In Babel 7, preset-env’s modules option defaults to "auto", which converts ES modules to CommonJS unless the caller (such as babel-loader) says it supports ESM. When you run Babel yourself to produce code for a bundler, set "modules": false; otherwise the bundler receives CommonJS and cannot tree-shake it.
Polyfills: useBuiltIns and core-js
Polyfill injection in Babel 7 is controlled by two preset-env options:
{
"presets": [
["@babel/preset-env", { "useBuiltIns": "usage", "corejs": "3.40" }]
]
}
npm install core-js@3
"usage"scans each file and addsimports for the features that file uses and your targets lack, as in the IE 11 output above."entry"expects you to writeimport "core-js/stable";once in your entry file and replaces it with every module your targets need, whether you use it or not. It is larger but also covers code Babel never sees.false(the default) adds nothing.
Set corejs to the minor version you actually installed ("3.40", not just 3). With only 3, Babel assumes 3.0 and will not inject polyfills for features added in later core-js releases, so newer methods silently go unpolyfilled.
The trade-off with usage is visibility. It sees only files that Babel compiles, and most setups exclude node_modules. If a dependency ships modern code that calls, say, Array.prototype.at, the polyfill is never added and the app breaks only in the old browser. Detection is also static: Babel cannot know that x.includes() is an array method rather than a string method, so it adds both polyfills; and code that calls methods dynamically (obj[name]()) is invisible to it.
Polyfill bugs are among the most frustrating ones I have to track down, because they never show up on the developer’s machine. Everything works in a current browser, and the report comes from someone on an older device with TypeError: xs.findLast is not a function or undefined is not a function from minified code. When I see that error only on old browsers, the first thing I check is whether the failing code came from node_modules and was therefore never scanned by useBuiltIns: "usage".
Apps vs libraries: @babel/plugin-transform-runtime
Babel’s syntax transforms rely on small helper functions (_classCallCheck, _objectSpread, _asyncToGenerator). By default these are inlined at the top of every file that needs them. In an app with hundreds of files, the same helpers are duplicated hundreds of times. @babel/plugin-transform-runtime replaces them with imports from @babel/runtime:
npm install -D @babel/plugin-transform-runtime
npm install @babel/runtime
{
"plugins": ["@babel/plugin-transform-runtime"]
}
With the plugin, the output starts with require("@babel/runtime/helpers/classCallCheck") and similar lines instead of full function bodies. Note that @babel/runtime must be a regular dependency, not a dev dependency, because the compiled code imports it at run time.
The plugin has a second mode that matters mainly for libraries. useBuiltIns: "usage" polyfills by modifying globals: it makes Array.prototype.findLast exist for everyone on the page. That is acceptable in an application, which owns the page. It is not acceptable in a library, which should not change the environment of the app that installs it. For libraries in Babel 7, the usual pattern is to leave useBuiltIns off and let transform-runtime rewrite API calls to imports from a non-global core-js build:
{
"presets": [["@babel/preset-env", { "modules": false }]],
"plugins": [["@babel/plugin-transform-runtime", { "corejs": 3 }]]
}
This requires @babel/runtime-corejs3 as a dependency instead of @babel/runtime. Many library authors go further and ship untranspiled modern syntax with no polyfills at all, documenting the minimum environment and leaving polyfilling to the application.
What you should not do is combine useBuiltIns: "usage" with transform-runtime’s corejs option. You then get both global and non-global polyfills for the same features, which is one of the ways a polyfill bundle doubles in size.
React and TypeScript presets
npm install -D @babel/preset-react @babel/preset-typescript
{
"presets": [
["@babel/preset-env", { "modules": false }],
["@babel/preset-react", { "runtime": "automatic" }],
"@babel/preset-typescript"
]
}
"runtime": "automatic" compiles JSX to imports from react/jsx-runtime, so files no longer need import React from 'react'. With the older classic runtime, a missing import fails at run time with ReferenceError: React is not defined.
Preset order is reversed: presets run from last to first, so the TypeScript preset strips types before the others see the code. Plugins run before presets, in the order listed.
@babel/preset-typescript removes type annotations and does not check anything. Code like const n: number = 'text' compiles without a warning. Pair it with tsc --noEmit in CI or your editor. Because Babel compiles each file on its own, TypeScript features that need information from other files cannot be compiled correctly, such as re-exporting a type without export type. Setting "isolatedModules": true in tsconfig.json makes tsc flag these cases so they do not surprise you at build time.
Decorators
Decorators are the best example of why Babel plugin options need care. There are two incompatible designs: the older “legacy” decorators that TypeScript’s experimentalDecorators implements (used by Angular, NestJS, TypeORM and MobX’s older API) and the standardized design (the 2023-11 version in Babel).
npm install -D @babel/plugin-proposal-decorators
{
"plugins": [
["@babel/plugin-proposal-decorators", { "version": "2023-11" }]
]
}
Use "legacy" instead if your code relies on experimentalDecorators semantics; decorator functions written for one design do not work with the other. Leaving the option out fails in Babel 7 with:
The decorators plugin, when .version is '2018-09' or not specified, requires a 'decoratorsBeforeExport' option, whose value must be a boolean.
and in Babel 8, which only accepts the final and legacy versions, with:
The decorators plugin requires a 'version' option, whose value must be one of: '2023-11' or 'legacy'.
Older tutorials that specify intermediate versions such as 2023-05 or 2022-03 work in Babel 7 but fail after upgrading.
Using Babel with bundlers
Webpack
npm install -D babel-loader
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: { loader: 'babel-loader', options: { cacheDirectory: true } },
},
],
},
resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'] },
};
babel-loader tells Babel that webpack understands ES modules, so preset-env leaves import/export alone and tree shaking works. cacheDirectory caches results between builds, which makes a large difference on rebuilds.
exclude: /node_modules/ is the right default for build speed, but it is exactly what causes the “dependency ships modern syntax” failure described in the polyfills section. If one package needs transpiling, narrow the exclusion rather than removing it: exclude: /node_modules\/(?!some-modern-package)/.
Vite
Vite does not use Babel for its own transforms; it uses esbuild in development and its bundler for production builds. @vitejs/plugin-react accepts Babel plugins when you need a transform esbuild does not provide:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['@babel/plugin-proposal-decorators', { version: '2023-11' }]],
},
}),
],
});
For legacy browser support in a Vite app, the dedicated tool is @vitejs/plugin-legacy, which uses Babel internally to produce a separate legacy bundle with polyfills. You do not need to configure preset-env yourself in that case.
Moving to Babel 8
Babel 8 is a major release with breaking changes, and many ecosystem tools still pin Babel 7, so check your framework’s support before upgrading. The changes most likely to break an existing config:
-
Node.js version. Babel 8 drops support for older Node.js releases. Check the
enginesfield of the@babel/coreversion you install against the Node version your CI uses before anything else. -
Polyfill options left preset-env.
useBuiltInsandcorejsare rejected:The 'useBuiltIns' option has been removed. Please use babel-plugin-polyfill-corejs3 instead.The replacement is the
babel-plugin-polyfill-corejs3plugin, configured with amethodsuch as"usage-global"or"entry-global"and a core-jsversion. Its README documents the option mapping. -
ES modules are preserved by default. Running the same file through
@babel/cliproduced CommonJSrequire/exportswith Babel 7 and keptimport/exportwith Babel 8. Code or tests that relied on the automatic CommonJS conversion need@babel/plugin-transform-modules-commonjsexplicitly. -
Decorator versions. Only
"2023-11"and"legacy"remain, as shown above.
My approach to such upgrades is to compile the whole source tree with the old and new versions into two directories and diff the output before running anything. Most of the changes above show up immediately as a config error; the module-format change does not, and a diff catches it before the test suite fails for confusing reasons.
Frequently Asked Questions
Q. Babel, SWC or esbuild?
A. SWC and esbuild are much faster for syntax lowering, JSX and TypeScript stripping, and they are the default in many modern toolchains. Babel remains the choice when you rely on specific Babel plugins or custom AST transforms, or when your framework’s tooling is built on it.
Q. What is the difference between useBuiltIns: "usage" and "entry"?
A. With entry, you import core-js once at your entry point and Babel replaces it with every polyfill your targets need, used or not. With usage, Babel adds polyfill imports per file only for features that file uses, which is usually smaller but does not cover dependencies in node_modules that Babel does not compile.
Q. Does Babel type-check TypeScript?
A. No. @babel/preset-typescript only removes type syntax. Run tsc --noEmit separately, in CI or as a pre-commit step, to catch type errors.