Transpilation with Babel
JavaScript moves fast, but the environments that run it do not. You may want to write optional chaining, top-level await, or class fields today, yet still support a corporate browser or an older Node.js LTS. Babel solves this by transpiling modern source into equivalent code that older engines understand, so you can write tomorrow’s syntax and ship it everywhere.
Transpilation vs polyfilling
These two terms get used interchangeably, but they solve different problems. Knowing the distinction tells you what Babel can and cannot do for you.
Transpilation rewrites syntax. New language grammar — arrow functions, classes, optional chaining (?.), nullish coalescing (??), spread — has no equivalent older engines can parse. Babel transforms it into older syntax that behaves identically.
Polyfilling supplies missing runtime APIs. Methods and globals like Array.prototype.flat, Promise, Object.fromEntries, or structuredClone are functions, not syntax. No amount of rewriting creates them; you must inject an implementation at runtime (historically via core-js).
| Concern | Example | Fixed by |
|---|---|---|
| New syntax | a?.b, class { #x = 1 } | Transpilation |
| New runtime API | [1,2].flat(), Promise.any | Polyfill |
| New built-in object | Map, WeakRef | Polyfill |
A pure transpiler can turn
a?.binto a safe member access, but it can never inventArray.prototype.flat. If your target environment lacks the method, you need a polyfill alongside Babel.
Presets do the heavy lifting
You rarely configure individual transforms. Instead you use a preset — a curated bundle of plugins. The one that matters for almost every project is @babel/preset-env. Given a list of target environments, it automatically enables exactly the transforms (and only the polyfills) those targets need. Newer targets get less transformation, which means smaller, faster output.
npm install --save-dev @babel/core @babel/cli @babel/preset-env
npm install core-js
A minimal config in babel.config.json:
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": "3.37"
}
]
]
}
With useBuiltIns: "usage", Babel scans each file and imports only the core-js polyfills that file actually references, based on your targets. This avoids shipping the entire polyfill library to every user.
Targeting browsers with browserslist
Babel does not guess which browsers to support — you tell it through browserslist, a shared config format that bundlers, autoprefixer, and Babel all read. Defining targets once keeps your whole toolchain consistent. The cleanest place is a browserslist key in package.json:
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"Firefox ESR"
]
}
Each line is a query that resolves against real-world usage data:
| Query | Meaning |
|---|---|
> 0.5% | Browsers with more than 0.5% global market share |
last 2 versions | The two most recent versions of every browser |
not dead | Excludes browsers without official support for 24 months |
Firefox ESR | The current Extended Support Release |
To target Node.js instead, set it explicitly in the Babel options rather than browserslist:
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }]
]
}
Inspect what your queries actually resolve to before committing — this is invaluable for spotting an ancient browser dragging in heavy transforms:
npx browserslist
Output:
chrome 126
edge 126
firefox 127
firefox 115
safari 17.5
samsung 25
Seeing it in action
Consider a small module using modern syntax:
const getName = (user) => user?.profile?.name ?? "anonymous";
export const greet = (user) => `Hello, ${getName(user)}!`;
Run it through Babel for a legacy target:
npx babel src/greet.js --out-file dist/greet.js
Targeting older browsers, the optional chaining and nullish coalescing are expanded into defensive checks that any ES5 engine can run, while the template literal becomes string concatenation. The behavior is byte-for-byte equivalent; only the syntax changed.
Where it fits in the build
Babel is almost never the last word in a real build. Today, bundlers and dev servers (Vite, webpack, esbuild, SWC) own the orchestration, and Babel runs as a transform step inside them — for example, via babel-loader in webpack. The typical pipeline looks like this:
source (.js/.ts/.jsx)
|
Babel transpile <- syntax down-leveling + polyfill injection
|
bundle + tree-shake
|
minify
|
output (dist/)
Many modern setups now reach for SWC or esbuild, which transpile far faster because they are written in Rust/Go. Babel still wins on its huge plugin ecosystem and precise polyfilling, so reach for it when you need control rather than raw speed.
Best practices
- Drive targets from a single
browserslistconfig so Babel, bundlers, and autoprefixer agree. - Prefer
@babel/preset-envover hand-picking plugins; let your targets decide the transforms. - Use
useBuiltIns: "usage"with a pinnedcorejsversion to ship the minimum polyfill payload. - Run
npx browserslistperiodically; trimming dead browsers can dramatically shrink output. - Don’t over-transpile — modern targets need fewer transforms, smaller bundles, and faster code.
- Keep Babel config in
babel.config.json(project-wide) rather than.babelrcfor monorepo friendliness.