Transpilers & Build Tools
You write modern JavaScript; your users run browsers of wildly different ages. A transpiler bridges that gap by translating new syntax into older, more widely supported syntax. Around transpilers grew an entire ecosystem of build tools — Babel, TypeScript, Vite, esbuild, SWC, webpack — that also bundle, minify, and optimize your code for production.
This page explains why transpilation exists, how the major tools relate to each other, and why the modern trend is actually toward less transpilation.
Why transpile?
TC39 ships new JavaScript features every year, but users don't all update their browsers on schedule. Without help, this modern code crashes on older engines:
// Modern source — optional chaining + nullish coalescing (ES2020) const city = user?.address?.city ?? 'Unknown'
A transpiler rewrites it into equivalent code that even an old engine understands:
// Transpiled output (roughly)
var _user$address
var city =
(_user$address = user === null || user === void 0 ? void 0 : user.address) ===
null || _user$address === void 0
? 'Unknown'
: _user$address.city !== null && _user$address.city !== void 0
? _user$address.city
: 'Unknown'Transpilation lets you write for the future and ship for the present: developers use the latest language features immediately, while the build step guarantees compatibility with whatever browsers the project must support.
Babel: the original workhorse
Babel made ES6 usable years before browsers supported it, and it's still everywhere. Its architecture is a pipeline: parse code into an AST, run plugins that transform the AST, print JavaScript back out.
Plugins — each transforms one feature, e.g.
@babel/plugin-transform-arrow-functionsPresets — curated bundles of plugins;
@babel/preset-envis the one you actually use@babel/preset-react— transforms JSX;@babel/preset-typescript— strips TS types
npm install --save-dev @babel/core @babel/cli @babel/preset-env npx babel src --out-dir dist
// babel.config.json
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": "3.36"
}
]
]
}The magic of preset-env is that it transpiles only what your targets need. You declare targets with Browserslist in package.json:
{
"browserslist": [
">0.5%",
"last 2 versions",
"not dead",
"not op_mini all"
]
}If every targeted browser already supports arrow functions, Babel leaves them alone. Tighten your targets and your bundle shrinks; loosen them and Babel does more work. The same Browserslist config also drives Autoprefixer (CSS) and other tools.
Polyfills vs syntax transforms
Transpilers can only rewrite syntax. New APIs — Promise, Array.prototype.at, structuredClone — are runtime features that can't be expressed in older syntax; they must be polyfilled: a script defines the missing feature at runtime.
Syntax transform | Polyfill | |
|---|---|---|
What it fixes | New grammar: arrow functions, classes, | New APIs: |
How | Rewrites code at build time | Defines the feature at runtime |
Tool | Babel, SWC, esbuild, TypeScript | core-js, or targeted single polyfills |
Cost | Slightly larger/uglier output | Extra runtime code shipped to everyone |
The standard polyfill library is core-js. With useBuiltIns: "usage" (shown above), Babel automatically injects only the core-js polyfills your code actually uses, sized to your Browserslist targets.
fetch on a runtime that lacks it, no amount of transpilation helps — the function simply does not exist. Know which of your problems are syntax problems (transpile) and which are API problems (polyfill).TypeScript's compiler as a transpiler
The TypeScript compiler tsc does two independent jobs: type checking and emitting JavaScript. The emit side is a transpiler — it strips type annotations and can down-level syntax to a chosen target:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020", // which JS version to emit
"module": "ESNext",
"outDir": "dist"
}
}In modern setups the two jobs are often split: a fast tool (esbuild, SWC, Babel) strips types and transpiles for speed, while tsc --noEmit runs separately just to check types. That's exactly how Vite handles TypeScript.
The modern toolchain: bundlers and fast transpilers
Transpilers rarely run alone — they're one stage inside a bundler, which resolves your import graph and produces optimized files for the browser:
Tool | Written in | Role | Known for |
|---|---|---|---|
webpack | JavaScript | Bundler | Maximum configurability; the long-time default |
Vite | JS (uses esbuild + Rollup) | Dev server + bundler | Instant dev startup, sensible defaults |
esbuild | Go | Bundler + transpiler | Extreme speed (10–100x Babel) |
SWC | Rust | Transpiler (+ bundling) | Babel-compatible speed; used by Next.js |
Rollup | JavaScript | Bundler | Clean ES module output; powers Vite builds |
Parcel | JS/Rust | Bundler | True zero-config experience |
A typical Vite project shows the division of labor: during development, Vite serves your files as native ES modules and transpiles each file on demand with esbuild (milliseconds). For production, it bundles with Rollup, minifies, and applies your Browserslist targets. You get modern speed with legacy compatibility.
npm create vite@latest my-app cd my-app && npm install npm run dev # instant dev server, on-demand transpilation npm run build # optimized production bundle in dist/
vite v5.4.2 building for production... ✓ 34 modules transformed. dist/index.html 0.46 kB │ gzip: 0.30 kB dist/assets/index-BQx3Zq1k.js 143.21 kB │ gzip: 46.13 kB ✓ built in 1.02s
Source maps: debugging the code you wrote
Transpiled, bundled, minified output is unreadable — so how do you debug it? Source maps are companion .map files that map every position in the generated code back to your original source. With them, browser DevTools show your actual files, original variable names, and correct stack traces.
// vite.config.js
export default {
build: {
sourcemap: true, // emit .map files alongside the bundle
},
}Generated files end with a comment like
//# sourceMappingURL=index.js.mappointing at the map.Error monitoring services (Sentry etc.) use uploaded source maps to un-minify production stack traces.
If you don't want to expose source publicly, upload maps to your error tracker but don't deploy them to the CDN.
Differential serving: module / nomodule
For a while, sites shipped two bundles: a small modern one for evergreen browsers and a heavier transpiled one for legacy browsers, using a clever HTML trick — old browsers ignore type="module", and modern browsers ignore nomodule:
<!-- Modern browsers load this (and skip the nomodule script) --> <script type="module" src="/app.modern.js"></script> <!-- Legacy browsers load this (and skip the module script) --> <script nomodule src="/app.legacy.js"></script>
Today this pattern is mostly historical: browsers that don't support modules (essentially IE11) are gone from most audiences. If you still need it, @vitejs/plugin-legacy generates the legacy bundle automatically. Knowing that ES-module support itself is the compatibility line explains a lot of modern tooling defaults.
The trend: less transpilation
Here's the plot twist of the 2020s: the transpilation gap is closing. Evergreen, auto-updating browsers mean the features of ES2017–ES2020 (async/await, optional chaining, modules) are supported by effectively all users. The consequences:
Default build targets moved up — Vite targets roughly ES2020-era "baseline" browsers out of the box, not ES5.
Transpiling to ES5 by default now mostly wastes bytes: transpiled
async/await(regenerator) is far larger and slower than the native syntax.Node.js needs no transpilation at all for modern syntax — and since Node 22+ it can even strip TypeScript types natively for many files.
Speed replaced capability as the differentiator — hence Babel (JS) giving ground to esbuild (Go) and SWC (Rust).
Some libraries now publish untranspiled modern JavaScript and let the application's build pipeline decide how far down to compile.
Choosing your setup
Simple site or learning project — no build step at all. Native ES modules in the browser work fine.
Modern app (React, Vue, vanilla) — Vite. esbuild-powered dev server, Rollup production builds, TypeScript out of the box.
Framework projects — Next.js (SWC), Astro/SvelteKit/Nuxt (Vite) bring a tuned pipeline; you rarely touch the transpiler directly.
Legacy or highly custom builds — webpack + Babel remains the most configurable combination.
Publishing a library — build with esbuild/Rollup/tsup, emit modern ESM (plus CJS if needed), and document your supported targets.