Skip to content
Chapter 94Lesson 3

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.

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 more

The 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
lucide-react/index.ts barrel — re-exports everything
pencil.js
trash.js
camera.js
user.js
…1500 modules
One named import points at the barrel, so the bundler now has the whole library on the table: it must prove each of the other ~1500 modules is safe to drop.

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.

Barrel import
~600KB: the whole library in your bundle
Per-export shape
~30KB: only the icons you used

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 write
import { 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 *.

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.

1 / 1

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.

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:

  1. Keep the barrel re-export-only. No logic, no side-effect modules anywhere in the chain. The index.ts exists to re-export and nothing else.
  2. Declare "sideEffects": false in the package’s package.json, or the array form if it ships CSS.
  3. Add the package name to experimental.optimizePackageImports so 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.

Leaks the whole library The barrel comes along for the ride
Ships only what's used Rewritten or shaken to the per-export shape
import { Pencil } from 'lucide-react'
An internal ui barrel with no sideEffects flag
import { debounce } from 'lodash'
import { format } from 'date-fns'
import { Tooltip } from 'recharts'
An internal ui barrel with sideEffects: false, listed in optimizePackageImports

At any import site from a multi-export package, walk these questions in order.

Importing from a multi-export package

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.

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.

next.config.ts
const nextConfig = {
// …other config (security headers, reactCompiler, cacheComponents)
___: {
___: ['@acme/ui'],
},
};
// packages/ui/package.json
{ "name": "@acme/ui", "___": false }

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.