Compiling JS and TS with SWC: .swcrc Configuration, JSX, Minification and Next.js/Webpack Use
Key takeaways
SWC (Speedy Web Compiler) is a JavaScript/TypeScript compiler written in Rust. It is much faster than Babel because it runs as native, multi-threaded code, and it is the default compiler in Next.js and Parcel.
Introduction
SWC (Speedy Web Compiler) is a TypeScript/JavaScript compiler and bundler written in Rust. It’s designed to be a drop-in replacement for Babel that is much faster on large codebases.
This post covers SWC as it is actually used: the .swcrc configuration, transpiling modern JavaScript, TypeScript and JSX, the built-in minifier, and plugging it into Node.js, Next.js, Webpack, Vite and Jest. It closes with a dual ESM/CommonJS library build and a step-by-step migration from Babel, including the plugin gaps that tend to block it.
Why It Is Faster
The SWC project’s own benchmarks report roughly 20x Babel’s speed on a single thread and up to about 70x on four cores; how much of that you see depends on your codebase, your Babel plugins and your machine, so measure your own build before and after. Compilation is also only one part of an application build: bundling, CSS, type checking, and test setup do not get faster just because the transpiler did.
The gap isn’t a clever optimization on top of the same fundamental approach Babel takes — it’s a different implementation language solving the same problem. Babel is written in JavaScript and runs on a single thread inside Node (parallelizing across files requires spinning up worker processes yourself), while SWC is written in Rust, compiled to native code with real multi-threading built in, parsing and transforming your code without the JIT-warmup and garbage-collection overhead a JavaScript-based compiler pays on every run. This is exactly why a large codebase sees a bigger relative speedup than a tiny one — the fixed overhead of starting up a JS-based toolchain amortizes away on a big job, so the proportional win from switching to a native compiler grows with codebase size, which is also why a speedup measured on one small file tells you little about a full project build.
Installation
npm install --save-dev @swc/core @swc/cli
Standalone Usage
# Compile single file
npx swc src/index.js -o dist/index.js
# Compile directory
npx swc src -d dist
# Watch mode
npx swc src -d dist --watch
Worth knowing up front that the standalone CLI is really a thin wrapper for quick transpilation tasks — it compiles files, but it doesn’t bundle them (resolve imports across files into a single output, the way Webpack or Rollup does). SWC’s actual sweet spot in most real projects isn’t as a standalone tool at all, it’s as the compilation step plugged into a bundler or test runner that still handles the bigger picture — which is exactly the shape of every integration covered later in this guide (Next.js, Webpack, Vite, Jest): SWC replaces the slow part (parsing and transforming JS/TS/JSX), while the surrounding tool keeps doing what it already did well.
Configuration
// .swcrc
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": true
},
"transform": {
"react": {
"runtime": "automatic"
}
},
"target": "es2020"
},
"module": {
"type": "es6"
}
}
.swcrc mirrors what .babelrc does conceptually — a declarative description of what syntax to accept and what to emit — but the nested jsc.parser/jsc.transform/jsc.target shape is specific to SWC and doesn’t map one-to-one onto Babel’s flatter preset/plugin list, which is exactly why the Migration section later in this guide exists as a dedicated walkthrough rather than a one-line “just rename the file.” target: "es2020" here is worth treating as a real decision, not a default to leave alone — it determines how much of modern syntax SWC actually has to down-level into older equivalents, and setting it more conservatively than your actual supported browsers require means paying transpilation cost (both build time and larger output) for compatibility nobody needs.
JavaScript Transpilation
ES6+ to ES5
// .swcrc
{
"jsc": {
"parser": {
"syntax": "ecmascript"
},
"target": "es5"
}
}
// Input
const add = (a, b) => a + b;
class User {
constructor(name) {
this.name = name;
}
}
// Output (ES5)
var add = function(a, b) {
return a + b;
};
var User = function User(name) {
this.name = name;
};
The arrow function losing its arrow syntax and the class losing its class keyword entirely (becoming a named function expression instead) are both necessary, not stylistic choices — ES5 genuinely has no arrow functions and no class syntax at all, so target: "es5" isn’t just “make the code look older,” it’s SWC finding a semantically-equivalent way to express the same behavior using only constructs ES5 actually supports. This matters concretely for this binding: an arrow function’s this is lexically inherited from its enclosing scope by spec, and the compiled var add = function(a, b) {...} preserves that here because this particular arrow doesn’t reference this at all — but an arrow function that does use this gets compiled with additional var _this = this capturing logic to replicate the same lexical binding using only ES5-legal syntax, which is worth knowing exists even though it doesn’t show up in this simple example.
TypeScript
npm install --save-dev @swc/core
// .swcrc
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": true
},
"target": "es2020"
}
}
// input.ts
interface User {
name: string;
age: number;
}
const user: User = {
name: 'Alice',
age: 30
};
npx swc input.ts -o output.js
Worth being explicit about a real limitation here that trips people up coming from tsc: SWC strips TypeScript types, it does not type-check them — it can compile code with a genuine type error (assigning a number where the interface declares string) without complaint, because it’s only concerned with producing valid JavaScript output, not verifying the types are internally consistent. This is precisely why tsc --noEmit running separately in CI (mentioned as a Vite/esbuild pattern elsewhere on this site, and equally true here) remains necessary in an SWC-based toolchain — SWC replaces the slow compilation step, not the type-checking step, and skipping the latter under the assumption “SWC handles TypeScript” is how type errors quietly ship to production.
React/JSX
// .swcrc
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true
},
"transform": {
"react": {
"runtime": "automatic", // React 17+
"pragma": "React.createElement",
"pragmaFrag": "React.Fragment"
}
}
}
}
Worth flagging that pragma/pragmaFrag here are actually inert given runtime: "automatic" — those two options only apply to the older “classic” runtime, where compiled output calls a manually-specified function (React.createElement by default, hence the option’s name) instead of importing directly from react/jsx-runtime the way the automatic runtime does. Keeping them in a config that also sets runtime: "automatic" is harmless (SWC just ignores them) but it’s worth removing them if you’re not using classic mode, both to avoid the confusion of two contradictory-looking settings and because they’re a leftover from the pre-React-17 pragma-based JSX convention this output correctly moved away from, as the actual compiled output below demonstrates.
// Input
function App() {
return <div>Hello, SWC!</div>;
}
// Output (automatic runtime)
import { jsx as _jsx } from 'react/jsx-runtime';
function App() {
return _jsx('div', { children: 'Hello, SWC!' });
}
The automatic runtime’s real win over the classic React.createElement pattern is that it no longer requires import React from 'react' in every single file that uses JSX — the compiler auto-injects exactly the specific jsx/jsxs imports each file actually needs from react/jsx-runtime, rather than assuming every JSX-using file needs the entire React namespace available. This is a small but real bundle-size and ergonomics improvement across a codebase with hundreds of components, and it’s the default React itself recommends for any project targeting React 17+, which is exactly why it’s the default shown throughout this guide rather than the older pragma-based classic mode.
Minification
SWC includes a super-fast minifier:
npm install --save-dev @swc/core
// swc.config.js
module.exports = {
minify: true,
jsc: {
minify: {
compress: {
unused: true
},
mangle: true
}
}
};
npx swc src/index.js -o dist/index.min.js --config-file swc.config.js
SWC bundling minification into the same native-compiled binary as the compiler itself, rather than requiring a separate tool (Terser, historically the standard JS minifier), is a meaningfully bigger deal than it might look — minification on a large codebase is often the single slowest step in a production build, and running it as another JS-based pass (Terser is itself written in JavaScript) reintroduces exactly the single-threaded, JIT-overhead bottleneck SWC’s compilation step was built to avoid in the first place. Having one native toolchain handle both compilation and minification, rather than a fast native compiler feeding into a slow JS-based minifier, is part of why frameworks adopting SWC (Next.js, covered next) tend to see build-time wins beyond just the raw compilation speedup numbers.
Node.js Integration
@swc/register
npm install --save-dev @swc/register
// require-hook.js
require('@swc/register');
require('./typescript-code.ts');
@swc/register is a require-hook: once loaded, it intercepts subsequent require() calls for TypeScript/JSX files and transpiles them on the fly before Node ever sees the raw, un-runnable source, which is the same mechanism ts-node/babel-register use for the same purpose. It’s worth knowing this trades startup-time compilation cost for zero build-step convenience — genuinely handy for scripts, CLIs, or a dev server where you want to run TypeScript files directly without a separate build step, but it’s re-compiling on every process start rather than once ahead of time, which is the wrong tradeoff for anything that actually gets built and deployed as a production artifact.
Programmatic API
const swc = require('@swc/core');
swc.transform('const x = 1', {
jsc: {
parser: {
syntax: 'ecmascript'
},
target: 'es5'
}
}).then(output => {
console.log(output.code);
});
The programmatic API is what every integration in this guide is actually built on underneath — Next.js’s build pipeline, swc-loader for Webpack, @swc/jest, and every other tool covered below all call into essentially this same swc.transform-style interface internally, just wrapped in whatever plugin API their host tool expects. Understanding this one function is genuinely the foundation for the rest of the guide: every framework integration is really just “call swc.transform (or its Rust-native equivalent) at the right point in an existing build pipeline, with the right config.”
Next.js Integration
Next.js 12+ uses SWC by default:
// next.config.js
module.exports = {
// SWC is enabled by default
swcMinify: true, // Next.js 12-14 only; SWC minification is the default from 13 and the option was removed in 15
};
With SWC as the compiler, Next.js gets faster production builds and Fast Refresh, and its compiler options cover transforms that used to need Babel plugins, such as styled-components; next/jest uses the same SWC transform for tests.
This is worth knowing as a specific historical turning point rather than just a feature bullet: Next.js switched its default compiler from Babel to SWC starting with version 12, which was a large part of what made SWC go from “an interesting Rust project” to something the majority of the React ecosystem now runs without ever configuring directly. Most Next.js developers today are using SWC on every single build and never touch a .swcrc file at all — it’s entirely invisible unless you need to customize behavior a Babel plugin used to provide, at which point next.config.js’s compiler options (or falling back to a .babelrc, which disables SWC for compilation) become relevant.
Webpack Integration
npm install --save-dev swc-loader
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.tsx?$/,
use: {
loader: 'swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
tsx: true
}
}
}
}
}
]
}
};
Note this webpack config’s exclude pattern is conspicuously missing something worth calling out — unlike the Migration section’s webpack config later in this guide (which correctly excludes node_modules), this one has no exclude at all, meaning swc-loader would attempt to process every matching .tsx? file webpack encounters, including any that happen to live inside node_modules. In practice this is usually harmless for plain JS dependencies (webpack’s resolution rarely walks into node_modules for a .tsx? test this way), but it’s worth adding exclude: /node_modules/ as a matter of habit on any loader rule — processing files you never intended to touch is both wasted build time and a potential source of confusing errors if a dependency ships TypeScript source that isn’t meant to be compiled by your project’s own toolchain.
Vite Integration
npm install --save-dev @vitejs/plugin-react-swc
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [react()]
});
Worth remembering the tradeoff covered in more depth in the Vite guide elsewhere on this site: @vitejs/plugin-react-swc is an alternative to the default @vitejs/plugin-react (Babel-based), and the choice is really about which ecosystem you need more — SWC’s build speed, or the much larger and more mature Babel plugin ecosystem for anything beyond the common JSX/TS/decorators cases already covered by SWC’s built-in transforms. Vite’s dev server already uses esbuild for on-the-fly transforms regardless of which plugin you pick, so this choice specifically affects the production build path, not everyday dev-server speed, which is a distinction worth keeping straight when deciding whether switching plugins is actually worth it for a given project.
Jest Integration
npm install --save-dev @swc/jest
// jest.config.js
module.exports = {
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest', {
jsc: {
parser: {
syntax: 'typescript',
tsx: true
},
transform: {
react: {
runtime: 'automatic'
}
}
}
}]
}
};
Jest’s default ts-jest/Babel-based transform is one of the more commonly-cited slow points in a large test suite’s feedback loop — transforming every test file (and every file it imports) on every single test run adds up fast on a codebase with thousands of tests, and it’s exactly the kind of repetitive, parallelizable work SWC’s native performance is best suited for. Swapping in @swc/jest here is typically one of the highest-leverage, lowest-effort changes available for a slow test suite specifically because it requires no changes to the tests themselves — only the transform config changes, while everything about how Jest discovers, runs, and reports on tests stays exactly the same.
Advanced Features
Decorators
{
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
}
}
}
@sealed
class User {
@readonly
name: string;
}
legacyDecorator/decoratorMetadata are worth flagging as a genuine compatibility trap: they correspond to TypeScript’s original, experimental decorator proposal (experimentalDecorators: true in tsconfig.json), which is what frameworks like older NestJS/TypeORM/Angular versions rely on — but TC39’s decorators proposal has since evolved into a materially different, now-standardized syntax that TypeScript 5+ supports natively without the experimental flag. Mixing the two up produces decorators that parse but behave subtly differently (particularly around metadata reflection, which decoratorMetadata specifically exists to support for the legacy proposal) — worth checking which decorator model a project’s tsconfig.json and its decorator-dependent dependencies actually expect before assuming this config block applies as-is.
Constant Module Inlining
This isn’t TypeScript’s const enum feature — it’s a distinct SWC-specific transform: any reference to myLib.VERSION in your source gets replaced with the literal string "1.0.0" directly at compile time, as if you’d hardcoded it. This is genuinely useful for injecting build-time constants (a version number, a feature flag baked in at build time) without an actual runtime import resolving to a real module named myLib — the “module” here is virtual, existing only in this config, not a real file SWC looks up.
{
"jsc": {
"parser": {
"syntax": "typescript"
},
"transform": {
"constModules": {
"globals": {
"myLib": {
"VERSION": "1.0.0"
}
}
}
}
}
}
Optimization
{
"jsc": {
"minify": {
"compress": {
"arguments": true,
"arrows": true,
"booleans": true,
"collapse_vars": true,
"comparisons": true,
"computed_props": true,
"conditionals": true,
"dead_code": true,
"directives": true,
"drop_console": true,
"drop_debugger": true,
"evaluate": true,
"expression": false,
"hoist_funs": false,
"hoist_props": true,
"hoist_vars": false,
"if_return": true,
"join_vars": true,
"keep_classnames": false,
"keep_fargs": true,
"keep_fnames": false,
"keep_infinity": false,
"loops": true,
"negate_iife": true,
"properties": true,
"reduce_funcs": false,
"reduce_vars": false,
"side_effects": true,
"switches": true,
"typeofs": true,
"unsafe": false,
"unsafe_arrows": false,
"unsafe_comps": false,
"unsafe_Function": false,
"unsafe_math": false,
"unsafe_symbols": false,
"unsafe_methods": false,
"unsafe_proto": false,
"unsafe_regexp": false,
"unsafe_undefined": false,
"unused": true,
"const_to_let": true,
"pristine_globals": true
},
"mangle": true
}
}
}
This is deliberately the full option list, but not something to hand-tune from scratch — the defaults SWC ships with already reflect sensible choices for most projects, and the two flags worth actually paying attention to before deploying a customized minify config are drop_console and the whole family of unsafe* options, both false by default here for good reason. drop_console: true silently removes every console.log/console.warn/etc. call from production output — convenient for stripping debug logging automatically, but a real footgun if any code relies on a console.* call for something beyond pure debugging (a lightweight analytics shim built on console.log, however unusual that sounds, does exist in the wild). The unsafe* compress options trade strict spec-correctness for more aggressive size reduction — they’re called “unsafe” because they can change program behavior in edge cases involving NaN, prototype chains, or Function constructors that the safe optimizations never touch; leaving them false (the default shown here) is the right choice unless you’ve specifically profiled a size problem these particular optimizations would solve and understand what they might break.
Real-World Example
Library build with SWC:
// swc.config.js
module.exports = {
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
decorators: true
},
transform: {
react: {
runtime: 'automatic'
}
},
target: 'es2020',
loose: false,
externalHelpers: false,
keepClassNames: false,
minify: {
compress: {
unused: true
},
mangle: true
}
},
module: {
type: 'es6',
strict: false,
strictMode: true,
lazy: false,
noInterop: false
},
minify: process.env.NODE_ENV === 'production',
sourceMaps: true
};
// package.json
{
"scripts": {
"build:esm": "swc src -d dist/esm --config-file swc.config.js",
"build:cjs": "swc src -d dist/cjs --config-file swc.cjs.config.js",
"build": "npm run build:esm && npm run build:cjs"
}
}
This dual-build setup mirrors the same ESM/CommonJS dual-format story covered in the Rollup guide elsewhere on this site, worth recognizing as the same underlying problem showing up in a different toolchain — a published library generally can’t assume every consumer’s environment supports ES modules, so shipping both dist/esm and dist/cjs (each built with its own config specifying the right module.type) covers both import and require() consumers without forcing a choice on them. The separate swc.cjs.config.js referenced here would differ from the shown swc.config.js primarily in its module.type setting (commonjs instead of es6) — everything else about the transform (target, JSX handling, minification) typically stays identical between the two builds.
Performance Tips
Use Parallel Compilation
SWC automatically uses all CPU cores — this is worth understanding as one of the genuine structural reasons behind the “70x on four cores” figure the SWC site quotes, distinct from the raw native-vs-JIT speed difference covered earlier: Babel’s core transform pipeline is fundamentally single-threaded, and getting it to use multiple cores requires bolting on an external parallelization layer (like thread-loader in Webpack) yourself. SWC’s Rust implementation parallelizes file processing natively, with no configuration needed to benefit from it — the more cores available, the larger its advantage tends to be, which is why the gap between SWC and Babel tends to look even larger on a beefy CI runner than it does on a single-core-constrained environment.
Enable Caching
{
"jsc": {
"experimental": {
"cacheRoot": ".swc"
}
}
}
Worth adding .swc (or whatever cacheRoot you set) to .gitignore immediately after enabling this — the cache directory holds build artifacts specific to your local filesystem and SWC version, not something meant to be shared via version control, and committing it would bloat the repo with churn on every build for zero actual benefit to anyone pulling the code (each machine needs to build its own cache regardless).
Optimize Target
{
"jsc": {
"target": "es2020" // Don't transpile more than needed
},
"env": {
"targets": "> 0.5%, last 2 versions, not dead"
}
}
The env.targets browserslist query and the jsc.target ECMAScript version are solving overlapping but distinct problems worth not conflating: env.targets describes real browser usage data and lets SWC figure out the minimum syntax transforms needed for that actual audience, while jsc.target is a blunter, fixed ECMAScript version target. Using env.targets with an accurate, current browserslist query is generally the more precise approach — it can avoid transpiling syntax that’s actually already supported by every browser in your real target audience, where a fixed jsc.target might transpile more conservatively than your actual users need, producing larger output than necessary for no real compatibility benefit.
Migration from Babel
Step 1: Install SWC
npm uninstall babel-loader @babel/core @babel/preset-env
npm install --save-dev swc-loader
I’ve done this migration on a mid-sized codebase, and the actual friction wasn’t the config conversion (which really is close to mechanical, as this section shows) — it was discovering, one CI failure at a time, which Babel plugins the project depended on that had no SWC equivalent. Anything covering standard modern JS/TS/JSX syntax migrates cleanly, but codebases using more exotic Babel plugins (certain experimental proposal stages, some framework-specific transforms, custom in-house Babel plugins) can hit a wall where the plugin ecosystem gap the FAQ mentions becomes a real blocker rather than a footnote — worth auditing your actual .babelrc plugin list against SWC’s supported transforms before committing to the migration, not after ripping Babel out and discovering something doesn’t compile anymore.
Step 2: Convert Config
Babel:
// .babelrc
{
"presets": [
["@babel/preset-env", { "targets": "defaults" }],
"@babel/preset-react",
"@babel/preset-typescript"
]
}
SWC:
// .swcrc
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": true
},
"transform": {
"react": {
"runtime": "automatic"
}
},
"target": "es2015"
}
}
Notice the target versions genuinely differ between the two configs shown here (es2015 in the SWC version versus the Babel config’s "targets": "defaults" browserslist query) — that’s not an oversight to gloss over, it’s exactly the kind of detail a real migration has to reconcile explicitly. "defaults" resolves to whatever browserslist’s current default query covers (which shifts over time as that shared config updates), while a hardcoded es2015 target stays fixed regardless — silently swapping one convention for the other during a migration can change your actual output’s browser compatibility in ways that are easy to miss until a bug report comes in from an older browser.
Step 3: Update Webpack
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'swc-loader'
}
}
]
}
};
Related Articles
- Turbopack: the Next.js Bundler
- Babel: JavaScript Transpiler
- esbuild
- Vite
- Immer: Immutable State Updates