Why Barrel Files and CommonJS Neutralize Tree Shaking in Next.js Turbopack
There's a situation you'll likely encounter once your frontend project reaches a certain size. You write just one line — import { Button } from '@/components' — but the build output ends up including Modal, Tooltip, and DatePicker that you never used, bloating the bundle far beyond what you'd expect. Atlassian solved this exact problem in the Jira frontend and cut build times by 75%. If you're thinking "maybe our project won't see results that dramatic, but we could reasonably trim hundreds of KB of unnecessary code from the bundle," this article will show you how.
What makes it even more frustrating is that after switching to Turbopack, the problem feels even more opaque. You're using Turbopack because the fast HMR is great — but when you open the bundle analyzer, the results often don't match your expectations.
In this article, we'll look at how barrel files interfere with tree shaking and what the core difference between CommonJS and ESM really is. Then we'll walk through optimization strategies that actually work in a Turbopack environment, with code examples.
Core Concepts
The Relationship Between Turbopack and Barrel Files
Summary: Turbopack is fast, but there are known open issues where tree shaking doesn't behave as expected with the barrel file + CommonJS combination.
Turbopack is Next.js's built-in bundler written in Rust. The dev server stabilized in Next.js 15, and it became the default bundler in Next.js 16+. It provides dramatically faster HMR compared to Webpack, but there are still several tree shaking–related issues open on GitHub.
Two issues deserve particular attention. One is a case where tree shaking fails on re-exported enums, adding hundreds of KB to the bundle (#88009). The other is a reported case where code is removed too aggressively, causing runtime errors (#85172). Next.js 16.2 improved tree shaking for both static and dynamic import(), but scenarios where it still isn't perfect do exist.
Ultimately, even in a Turbopack environment, having a code structure that the bundler can analyze statically is a prerequisite for tree shaking. What gets in the way of that structure is barrel files and CommonJS.
Why Barrel Files Are a Problem
Summary: Importing a single barrel file forces the bundler to parse every module that file references.
Barrel files started with good intentions. As component libraries grew, a pattern naturally emerged of exporting everything from a single index.ts so consumers wouldn't have to memorize individual import paths.
// src/components/index.ts (barrel file)
export { Button } from './Button';
export { Modal } from './Modal';
export { Input } from './Input';
export { DatePicker } from './DatePicker';
// ...dozens to hundreds moreFrom the consumer's perspective, using import { Button } from '@/components' is clean and makes for a great developer experience (DX). But from the bundler's perspective, processing that single line requires parsing and evaluating every module the barrel file references. Even if you only need Button, it still has to look inside the files for Modal, Input, and DatePicker.
This is exactly the structure Atlassian's Jira frontend had. Barrel files pointing to other barrel files in a chain were transitively pulling in nearly every file in the project. Simply breaking that chain cut build times by 75%. If that's what it does at that scale, what might it do in your project? If you're managing a large component library or utility package with barrel files, it's worth opening the bundle analyzer to take a look.
The Real Reason CommonJS Blocks Tree Shaking
Summary: CJS is a runtime function call, making build-time static analysis impossible — the entire bundle is always included.
For tree shaking to work, the bundler needs to be able to determine at build time "which exports are used where." ESM's import/export are declarative statements that only exist at the top level of a file, which makes this analysis possible.
Tree Shaking vs Dead Code Elimination — These two terms are often used interchangeably, but they're subtly different. Dead code elimination is when a compiler removes unreachable paths within code, while tree shaking excludes entire unused exports from the bundle at the module graph level. Both techniques reduce final bundle size, but tree shaking only works fully on top of ESM's static structure.
CommonJS is different. require() is a runtime function call, so it can behave dynamically. The most common forms you'll encounter in practice look like this:
// CommonJS — the most common pattern
module.exports = { funcA, funcB, funcC };
// or
exports.default = MyClass;These patterns may look statically analyzable, but the bundler cannot guarantee that module.exports won't be modified later. There are even more extreme cases sometimes found in Node.js libraries:
// CommonJS — cases the bundler cannot analyze at build time
const key = process.env.FEATURE_FLAG;
module.exports[key] = () => { /* ... */ };
// conditional require
if (process.env.NODE_ENV === 'test') {
module.exports = require('./mock-utils');
}The bundler has no way of knowing how this code will be evaluated at runtime. So CommonJS modules are always included in the bundle in their entirety, regardless of whether they're used. If even one CJS module is mixed into a barrel file, tree shaking stops at that file boundary.
One more thing — even a barrel file written in ESM can suffer reduced tree shaking efficiency if it overuses the export * from './some-module' pattern. Because the bundler has to trace all re-exports from that module, using explicit named exports is safer when possible.
When TypeScript Configuration Silently Disables Tree Shaking
Summary: A tsconfig with "module": "CommonJS" will convert code you wrote as ESM into CJS.
Honestly, this is something I overlooked for quite a long time myself. If you have "module": "CommonJS" in your tsconfig.json, the TypeScript compiler converts all import/export statements into require()/module.exports. You write the code as ESM, but the compiled output is CJS.
// ❌ Configuration where tree shaking won't work
{
"compilerOptions": {
"module": "CommonJS"
}
}
// ✅ Configuration that passes the ESM structure through to the bundler
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
}
}"module": "Preserve" is also a good choice — it keeps the source file's module syntax intact and only strips type information. "moduleResolution": "bundler", introduced in TypeScript 5.0+, is a module resolution strategy optimized for bundler environments like Vite and Turbopack, so setting them together is recommended.
Practical Application
For those wondering "where do I even start," the items are ordered by priority. We begin with things that show results immediately without touching any code, then move on to package configuration and code migration.
Example 1: Automatically Optimize External Package Barrel Files with optimizePackageImports
This is a way to see immediate results without changing any code. The optimizePackageImports option introduced in Next.js 13.5 automatically converts barrel file imports from external packages into direct path imports.
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['lucide-react', '@mui/material', 'lodash', '@heroicons/react'],
},
};Internally, this transformation happens:
// Code you wrote
import { AlertCircle, Check } from 'lucide-react';
// What Next.js converts it to internally (automatically resolved to match the package's internal path structure)
import AlertCircle from 'lucide-react/dist/esm/icons/alert-circle';
import Check from 'lucide-react/dist/esm/icons/check';| Situation | Effect |
|---|---|
| lucide-react (10,000+ icons) | Removes 400KB+ from bundle, reduces cold start by hundreds of ms |
| @mui/material | Only used components are included in the bundle |
| Not applicable | Local workspace packages, symlink environments (issue #75148) |
Note —
optimizePackageImportsis exclusively for external packages innode_modules. It does not apply to internal barrel files within your project. There is also a known issue (#75148) where the optimization does not work for local packages linked via symlinks in a monorepo — the kind of setup where another workspace package is referenced as if it were innode_modules.
Example 2: Provide Hints to the Bundler with sideEffects: false
If you have an internal library or monorepo package, the next step is adding "sideEffects": false to its package.json. This declaration tells the bundler "simply importing any file in this package produces no side effects." Both Turbopack and Webpack can use this information to more aggressively eliminate unused modules.
I actually shipped to production once without this setting. When I checked the bundle analyzer, the entire @myapp/ui package was included as one big chunk. Adding that single line — "sideEffects": false — removed hundreds of KB from the bundle.
{
"name": "@myapp/ui",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}Side Effect — Code that causes side effects just by being imported: mutating global state, registering polyfills, injecting CSS, etc. You must verify whether this applies before declaring
"sideEffects": false. Declaring it incorrectly can cause needed code to be removed from the bundle.
For files that genuinely have side effects, like CSS files or global state initializers, you can list them explicitly in an array:
{
"sideEffects": ["./src/styles/global.css", "./src/polyfills.ts"]
}Example 3: Maintaining CJS Backward Compatibility with a Dual Package Strategy
If you want to keep compatibility with existing CJS environments while gaining the benefits of ESM tree shaking, you can provide both via conditional exports. This is a realistic compromise for the situation where "I'm worried about existing environments if I go ESM-only."
{
"name": "@myapp/utils",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./helpers": {
"import": "./dist/helpers.mjs",
"require": "./dist/helpers.cjs",
"types": "./dist/helpers.d.ts"
}
}
}The bundler will prefer the .mjs file under the import condition, so you get the full benefits of ESM tree shaking. CJS environments will automatically use the .cjs file.
On build tool selection: tsup is esbuild-based, so configuration is minimal and simultaneous CJS+ESM output is possible with a single line of config — making it the most practical choice for this purpose. Use rollup or unbuild when you need finer-grained control.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
clean: true,
});Example 4: Using Automated CJS→ESM Conversion Tools and Bundle Analysis
Manually converting CJS to ESM in a legacy codebase is quite tedious. A realistic approach is to use tools like cjstoesm or lebab to automate about 90% of the conversion and manually review the rest. Note that both tools have seen sparse recent updates, so conversion results must be reviewed carefully.
# Install and use cjstoesm
npx cjstoesm src/**/*.js
# Apply specific transforms with lebab
npx lebab --transform commonjs src/utils.js -o src/utils.mjsBefore and after conversion, it's recommended to verify bundle composition with @next/bundle-analyzer. One important caveat: if you've migrated your project to ESM, you'll also need to use import instead of require() in next.config.js, or rename the file to next.config.mjs.
// next.config.mjs (ESM project)
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withBundleAnalyzer({
// ...
});ANALYZE=true pnpm buildIf you want to catch barrel file imports at the lint level, you can use the no-barrel-files rule from eslint-plugin-import:
{
"plugins": ["import"],
"rules": {
"import/no-barrel-files": "warn"
}
}Pros and Cons Analysis
The table below is based on the ESM barrel file + sideEffects configuration combination.
Advantages
| Item | Description |
|---|---|
| Improved DX | Import paths on the consumer side become cleaner, and the external API remains stable even when internal file structure changes |
| API surface control | You can selectively expose only what should be public via barrel files, hiding internal implementation paths |
| ESM + sideEffects | Correctly configured ESM barrel files work well with tree shaking |
| Dual Package | You can maintain CJS backward compatibility while simultaneously benefiting from ESM bundle optimization |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Cold start overhead | Entire barrel file must be parsed, adding hundreds of ms to seconds on dev server first load depending on project size | Apply optimizePackageImports or use direct path imports |
| Tree shaking failure when CJS is mixed in | If even one CJS module exists in the barrel file, tree shaking stops at that boundary | Trace the dependency chain and migrate to ESM, or use Dual Package |
| Barrel-to-barrel chains | The entire project's modules can be transitively included | Minimize barrel file depth, consider applying no-barrel-files rule from eslint-plugin-import |
| Known Turbopack bugs | Tree shaking failure on re-exported enums (#88009), runtime errors from overly aggressive code removal (#85172) | Periodically check with bundle-analyzer, use direct path imports for problematic packages |
| Local workspace + symlinks | Known issue where optimizePackageImports optimization doesn't work (#75148) |
Use direct path imports until the issue is resolved |
Most Common Mistakes in Practice
-
Thinking you've migrated to ESM while still keeping
"module": "CommonJS"intsconfig.json— I fell into this trap myself. The TypeScript compiler converts everything back to CJS during the transpile step, so the code entering the bundler is effectively CJS. You need to change to"module": "ESNext"or"module": "Preserve". -
Declaring
"sideEffects": falseacross the entire package when it includes CSS modules or global initialization files — My team spent half a day on this issue. The bundler removed style files, causing visual breakage after deployment, and it took quite a while to identify the root cause. Files with side effects must be explicitly listed in the array. -
Re-exporting external CJS packages from within a barrel file — Even in an ESM file, importing a CJS module and re-exporting it halts tree shaking at that module boundary. It's best to first check whether the external CJS package supports Dual Package.
Closing Thoughts
Barrel files themselves aren't the problem — the problem arises when they're combined with structures the bundler can't analyze statically. Maintaining an ESM structure, correctly declaring the sideEffects field, and verifying TypeScript compilation settings alone can produce a noticeable reduction in bundle size.
Here are 3 steps you can take right now:
-
Assess your current bundle — Run
@next/bundle-analyzerwithANALYZE=true pnpm build. You'll get a visual picture of which packages are larger than expected, making it much easier to prioritize optimizations. If an icon package like lucide-react is taking up 400KB or more, the next step alone can reduce it immediately. -
Start with
optimizePackageImportsfor external packages — Add icon and utility packages like lucide-react, @mui/material, and lodash tonext.config.js. Without any code changes, you can immediately remove hundreds of KB to several MB from the bundle. -
Audit internal package
package.json— For monorepos or internal libraries, verify that"sideEffects": falseand the"exports"conditional fields are correctly configured. Check the"module"setting intsconfig.jsonat the same time. When these two are aligned, bundle size often drops to a noticeably smaller level.
If you run the bundle analyzer and find a problematic package, please share it in the comments — it'll be a great reference for others.
References
- Turbopack Dev is Now Stable | Next.js Blog
- Turbopack: What's New in Next.js 16.2 | Next.js Blog
- How we optimized package imports in Next.js - Vercel
- next.config.js: optimizePackageImports | Next.js Docs
- How CommonJS is making your bundles larger | web.dev
- Speeding up the JavaScript ecosystem - The barrel file debacle
- How We Achieved 75% Faster Builds by Removing Barrel Files - Atlassian
- Lazy barrel - Rspack
- Announcing Rspack 1.6 - Rspack Blog
- Tree Shaking | webpack
- Turbopack tree-shaking generates invalid output · Issue #85172
- Turbopack not tree-shaking unused enum exports · Issue #88009
- Publishing dual ESM+CJS packages - Mayank
- The Hidden Costs of Barrel Files - wesionary TEAM