Barrel files and tree-shaking
Why barrel re-exports defeat tree-shaking, and how Next.js optimizePackageImports and sideEffects keep imports lean.
Picture this: the bundle grew 300KB since the last release. You open the pull request expecting a fat new dependency, but there isn’t one. The diff is a few small components and one new line:
import { Pencil } from 'lucide-react';One named import, one icon, a third of a megabyte shipped to every visitor. The line looks innocent, which is why it cleared review. This lesson teaches you to read an import and tell whether it’s safe, plus the two-line fix that keeps the readable form while shipping only what you use.
That extra weight is one reason INP creeps up: the browser must parse and run client JavaScript before the page can respond. You’ll confirm the fix with the bundle analyzer next lesson; here the goal is to find the cause and reason about the fix.
What a barrel file is
Section titled “What a barrel file is”A barrel file is a package’s index.ts whose only job is to re-export everything from the modules underneath it.
Open lucide-react’s and you’d find roughly fifteen hundred lines like this:
export { default as Pencil } from './icons/pencil';export { default as Trash } from './icons/trash';// …1500 moreThe convenience is the point: you import from one path, 'lucide-react', and autocomplete offers the whole library without your knowing which file Pencil lives in.
That single path is also the catch.
When you write import { Pencil } from 'lucide-react', you aren’t importing one icon, you’re pointing the bundler at the barrel, and it now has to reason about the entire re-export graph to decide what it can leave out.
You might expect modern bundlers to tree-shake the rest away, dropping every export nothing imports. They do, but only when they can prove an export is safe to drop, and a barrel is the shape that makes that proof hard. Three things defeat it, all of which you’ll meet in real dependencies:
- Module-level side effects. If any module in the chain runs code just by being imported, such as registering something or mutating a global, the bundler can’t prove dropping it is safe, so it keeps it. More on switching this off below.
- Wildcard re-exports. A barrel built from
export * from './icons'is harder to trace statically than named re-exports, and some bundler-and-loader combinations give up and keep everything. - CommonJS interop . A barrel published as CommonJS can’t be shaken at all: its exports are computed at runtime, so there’s no static graph to prune. It’s all or nothing, and “all” is the whole library.
So “modern bundlers tree-shake, so barrels are free” is false often enough that a clean import line tells you nothing about how many bytes it drags in.
import { Pencil } from 'lucide-react'; the import site import { Pencil } from 'lucide-react'; the import site lucide-react: 1500 icons behind one import
Section titled “lucide-react: 1500 icons behind one import”Left unoptimized, import { Pencil } from 'lucide-react' can pull all fifteen hundred icon modules into your bundle.
The icons a real app renders, a pencil, a trash can, a chevron, and a dozen more, total well under 30KB.
The chunk balloons toward the hundreds of KB because the bundler couldn’t prove the rest were safe to drop.
A rough shape, not a measured reading: one barrel import versus the per-export shape. You’ll measure the real numbers with the bundle analyzer next lesson.
One catch: lucide-react is on Next.js’s default-optimized list.
In a Next.js 16 project that import is already rewritten for you, so the blow-up above is what would happen without the framework’s help.
So why spend a section on it? Because the list is finite, and the libraries it doesn’t cover are the ones that bite you: niche icon sets, a chart library, and above all your own internal packages. Lucide is the teaching vehicle; once you see the mechanism, you can spot the trap anywhere, including places Next.js will never know about.
The two fixes: optimizePackageImports vs deep imports
Section titled “The two fixes: optimizePackageImports vs deep imports”Two fixes make an icon import ship lean. They are not equal.
// what you writeimport { Pencil } from 'lucide-react';
// next.config.ts lists the package under experimental.optimizePackageImports,// and the build rewrites the line above to:// import Pencil from 'lucide-react/icons/pencil';Readable at the call site, lean in the bundle. This is the default. You write the ordinary barrel import, and Next.js rewrites it into per-export deep imports at build time, so the bundle ships only the icons you used. It scans only the entry barrel in one pass, cheaper than full tree-shaking, and handles nested barrels and export *.
import Pencil from 'lucide-react/icons/pencil';No config, but verbose and fragile. A direct deep import skips the barrel entirely: about 1KB, zero config. But it needs the library to expose typed deep paths, and if that path isn’t documented public API, a minor version can move it. It’s verbose at every import site. And if you mix it with a { ... } barrel import from the same package elsewhere, the barrel still loads for that line.
The decision is not close.
Default to optimizePackageImports. Reach for a per-icon deep import only when a library isn’t on Next.js’s list and you can’t get it added, which is rare, or as a quick local experiment.
A deep import at every call site is a tax the whole team pays forever; the config line is paid once.
const nextConfig = { // …other config (security headers, reactCompiler, cacheComponents) experimental: { optimizePackageImports: ['@acme/ui', 'some-icon-set'], },};It lives under experimental because, as of Next.js 16, optimizePackageImports still carries the experimental banner: widely used, but officially subject to change. Watch the release notes for when it graduates; don’t assume it already has.
const nextConfig = { // …other config (security headers, reactCompiler, cacheComponents) experimental: { optimizePackageImports: ['@acme/ui', 'some-icon-set'], },};List the packages you want rewritten, but only the ones not already on Next.js’s default list. Lucide, date-fns, and recharts are handled for you, so this is for the libraries Next.js doesn’t cover, most often your own internal packages.
const nextConfig = { // …other config (security headers, reactCompiler, cacheComponents) experimental: { optimizePackageImports: ['@acme/ui', 'some-icon-set'], },};The rewrite runs in production builds, not in pnpm dev, so a heavy dev bundle is expected, not a second bug to hunt. The lean output is what pnpm build produces.
sideEffects: false: promising the bundler nothing runs on import
Section titled “sideEffects: false: promising the bundler nothing runs on import”The bundler keeps any module it can’t prove is inert.
"sideEffects": false in a package’s package.json is that proof: importing any module here runs no import-time code, so dropping the unused exports changes nothing.
Without the flag the bundler plays it safe; with it, it can prune aggressively.
This is why lucide shakes so cleanly: it ships sideEffects: false.
The packages you set the flag on are your own.
{ "name": "@acme/ui", "sideEffects": false}One case makes a blanket false backfire.
If a package does have side-effect modules, such as a component that imports its own CSS or a polyfill that runs on import, false silently drops them, and your styles go missing in production with no error to point at.
Name those exceptions with the array form:
{ "sideEffects": ["*.css"]}A side effect is code that runs as a consequence of importing a module: registering a handler, mutating a global, or pulling in a stylesheet. A module with none can be dropped when nothing uses it; a module with one can’t, unless you tell the bundler which to keep.
The internal ui package is the same trap
Section titled “The internal ui package is the same trap”As a web app grows, the shared components, such as buttons, dialogs, and form fields, get factored into an internal ui package that every app imports from.
It gets a barrel index.ts, because that’s convenient:
export * from './button';export * from './dialog';export * from './field';Now you have the same trap in your own code, and Next.js’s default list has never heard of @acme/ui.
Import one button and you drag in the whole component library.
The fix mirrors the third-party case, in three moves:
- Keep the barrel re-export-only. No logic, no side-effect modules anywhere in the chain. The
index.tsexists to re-export and nothing else. - Declare
"sideEffects": falsein the package’spackage.json, or the array form if it ships CSS. - Add the package name to
experimental.optimizePackageImportsso Next.js rewrites every consumer’s imports.
This looks like it contradicts an earlier rule: the project conventions say no barrel files in lib/, db/, or app/_lib/, import the file you need.
Both hold.
An internal component library built for broad re-use is the one place a barrel earns its keep, because the autocomplete and single import path save every developer real time, provided it’s re-export-only, flagged sideEffects: false, and listed for rewriting.
Everywhere else, where you import one helper from one file, skip it.
Barrels aren’t banned; un-shakable barrels are.
For each import or package, decide whether it leaks the whole library into the bundle or already ships only what's used. Drag each item into the bucket it belongs to, then press Check.
import { Pencil } from 'lucide-react'ui barrel with no sideEffects flagimport { debounce } from 'lodash'import { format } from 'date-fns'import { Tooltip } from 'recharts'ui barrel with sideEffects: false, listed in optimizePackageImportsChoosing the fix at an import site
Section titled “Choosing the fix at an import site”At any import site from a multi-export package, walk these questions in order.
Write the readable { named } import. Next.js rewrites it to the per-export shape at build, so the lean bundle is automatic.
Set sideEffects: false (or the array form if it ships CSS) in its package.json, keep the barrel re-export-only, and add the package name to optimizePackageImports.
The preferred reach. A per-icon deep import is the no-config fallback if you can’t touch the config: verbose at every call site, and a semver risk.
Add it to optimizePackageImports regardless, since the entry-barrel scan still helps. If it can’t be rewritten, accept the cost and watch it in the analyzer next lesson.
The same frame covers more than icons.
date-fns is another multi-export package on the default list, so again there’s nothing to do.
Lodash is the cautionary case: plain lodash is CommonJS, so import { debounce } from 'lodash' can’t be shaken and pulls in the whole library, around 70KB, for one function.
Depend on lodash-es instead, which ships ES modules that shake to what you used, then run it through the same frame.
What the fix costs
Section titled “What the fix costs”The one cost worth naming is build time: optimizePackageImports rewrites your imports on every production build, trading build time for a smaller bundle, almost always a good trade.
The dev bundle still looks heavy, because the transform runs only in production.
Complete the two-line fix that ships your internal `ui` package lean. Pick the right option from each dropdown, then press Check.
const nextConfig = { // …other config (security headers, reactCompiler, cacheComponents) ___: { ___: ['@acme/ui'], },};
// packages/ui/package.json{ "name": "@acme/ui", "___": false }External resources
Section titled “External resources”The config doc, the explainer of the rewrite mechanism, the tree-shaking guide beneath it, and a tool to weigh any package before you install it.