Skip to content
Chapter 94Lesson 3

The barrel-export trap

How barrel files defeat tree-shaking and bloat your bundle, and how Next.js optimizePackageImports plus sideEffects keep imports lean.

The bundle grew 300KB since the last release. You open the PR that did it expecting a fat new dependency, and there isn’t one. The diff is a handful of small components and one new line:

import { Pencil } from 'lucide-react';

One named import, one icon, and a third of a megabyte of JavaScript shipped to every visitor. That line is the whole story, and it looks innocent, which is exactly why nobody caught it in review. This lesson teaches you how to read an import and know whether it’s safe, plus the two-line fix that lets you write the readable form while shipping only what you used. That extra weight is part of why INP creeps up: client JavaScript has to be parsed and run before the page can respond, which picks up the thread from earlier in this chapter where we defined the Core Web Vitals. The last lesson covered one big source of bundle weight, images; this lesson covers the other. You’ll prove the fix worked with the bundle analyzer in the next lesson. Here, the goal is to name 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 that all look like this:

export { default as Pencil } from './icons/pencil';
export { default as Trash } from './icons/trash';
// …1500 more

The appeal is real. You import from one path, 'lucide-react', and autocomplete offers you the entire library. You never go hunting for which file Pencil lives in. But that single import path is also the catch. When you write import { Pencil } from 'lucide-react', you’re not importing one icon; you’re pointing the bundler at the barrel, and now it has to reason about the entire re-export graph to decide what it’s allowed to leave out.

The instinct here is to shrug and say modern bundlers tree-shake , so they drop the exports nothing imports. That’s true, but tree-shaking only works when the bundler can prove an export is safe to drop, and a barrel is exactly the shape that makes that proof hard. Three things defeat it, and you’ll meet all three 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 that dropping it is safe, so it keeps it. We’ll come back to how you switch this off.
  • Wildcard re-exports. A barrel built from export * from './icons' is harder to follow statically than named re-exports, and some bundler-and-loader combinations give up and keep the lot.
  • CommonJS interop . A barrel published as CommonJS can’t be statically shaken at all. Its exports are computed at runtime, so the bundler has no static graph to prune. It’s all or nothing, and “all” is the whole library.

The point to carry out of this section is that “modern bundlers tree-shake, so barrels are free” is false often enough that you don’t get to assume it. A clean-looking 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, and now the bundler has the whole library on the table — it has to prove each of the other ~1500 modules is safe to drop.

The lucide-react case: 1500 icons behind one import

Section titled “The lucide-react case: 1500 icons behind one import”

Here is the canonical case made concrete. Left unoptimized, import { Pencil } from 'lucide-react' can pull on the order of fifteen hundred icon modules into your bundle. The icons a real app actually renders, a pencil, a trash can, a chevron, and a dozen more, would total well under 30KB. Yet the chunk balloons toward the hundreds of KB, because the bundler couldn’t prove the other fourteen-hundred-odd 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 in the next lesson.

There’s an important caveat, because it changes what this lesson is really teaching. lucide-react is on Next.js’s default-optimized list. In a Next.js 16 project, that exact import is already getting rewritten for you, so the blow-up above is what would happen without the framework’s help, and the framework helps by default.

So why spend a lesson on it? Because the list is finite, and the libraries it doesn’t cover are exactly the ones that bite you: niche icon sets, a chart library, and above all your own internal packages. Lucide is the teaching vehicle for the mechanism. Once you can see the mechanism, you can recognize the trap anywhere, including the places Next.js will never know about.

There are two ways to make an icon import like that one ship lean. They are not equal, and the course has a default, but seeing them side by side is what makes the default make sense.

// 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 just the entry barrel in one pass, which is cheaper than full tree-shaking, and it handles nested barrels and export *.

The decision is not close. Default to optimizePackageImports. Reach for a per-icon deep import only when a library you depend on isn’t on Next.js’s list and you can’t get it added, which is rare, or as a quick local experiment. The readable form wins because you should not have to trade legibility for bundle size, and that is the whole point of the lesson. A deep import at every call site is a tax your whole team pays forever, while a single line in the config is paid once.

So let’s look at that single line.

const nextConfig = {
// …other config (security headers, reactCompiler, cacheComponents)
experimental: {
optimizePackageImports: ['@acme/ui', 'some-icon-set'],
},
};

This lives under experimental because, as of Next.js 16, optimizePackageImports still carries the experimental banner: widely used, but officially subject to change. Keep an eye on the release notes for when it graduates, and 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. The catch worth remembering is that you only list packages that are not already on Next.js’s default list. Lucide, date-fns, recharts, and friends are handled for you, so what goes here is 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'],
},
};

This rewrite runs in production builds, not in pnpm dev. So when your dev bundle still looks heavy, that’s expected: don’t panic and don’t go looking for a second bug. The lean output is what pnpm build produces.

1 / 1

sideEffects: false: telling the bundler it’s safe to prune

Section titled “sideEffects: false: telling the bundler it’s safe to prune”

There’s a second half to the fix, and it’s the half you own.

Remember that the bundler keeps any module it can’t prove is inert. "sideEffects": false in a package’s package.json is that proof, stated as a promise: importing any module in this package runs no import-time code, so dropping the unused exports changes nothing. Without the flag, the bundler plays it safe and keeps imports it can’t reason about. With it, the bundler is allowed to prune aggressively.

This is why lucide shakes so cleanly in the first place: it ships sideEffects: false, which is also why it’s safe for Next.js to put on the default list. The place where you set this is your own packages.

{
"name": "@acme/ui",
"sideEffects": false
}

One nuance keeps this from backfiring. If a package genuinely does have side-effect modules, such as a component that imports its own CSS or a polyfill that runs on import, a blanket false silently drops them, and now your styles are missing in production with no error to point at. For those packages, use the array form to name the exceptions:

{
"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’ve told the bundler which ones to keep.

Here’s where the mechanism pays off, because this is the shape you’ll actually hit.

As a SaaS app grows, at some point the shared components, such as buttons, dialogs, and form fields, get factored into an internal ui package, a workspace or monorepo package every app imports from. It gets a barrel index.ts, because that’s the convenient thing to do:

export * from './button';
export * from './dialog';
export * from './field';

And now you have the exact 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 exactly. There are three moves, and this is the checklist worth memorizing:

  1. Keep the barrel re-export-only. No logic, and 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 might look like it contradicts a rule from earlier in the course. The project conventions say no barrel files in lib/, db/, or app/_lib/: import the file you need. Both things are true, and reconciling them is the nuance worth grasping here. An internal component library built for broad re-use is the one place a barrel earns its keep, because the autocomplete and the single import path are worth real money to every developer who uses it, provided it’s re-export-only, flagged sideEffects: false, and listed for rewriting. Everywhere else in your app code, where you’re importing one helper from one file, skip the barrel entirely, because the convenience isn’t worth the cost. 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

Put it all together as the order a senior actually asks the questions at an import site. The value isn’t any single answer; it’s the sequence, which you can replay in your head every time you reach for a multi-export package.

Importing from a multi-export package

The same frame covers more than icons. date-fns is the canonical second example: multi-export, on the default list, nothing for you to do. Lodash is the cautionary one. Plain lodash is CommonJS, so import { debounce } from 'lodash' can’t be shaken and drags in the full library (around 70KB) for one function. The fix is to depend on lodash-es instead, which ships ES modules that shake to just what you used, then give it the same protections. The pattern generalizes: the moment a package has many exports and one import path, ask the questions above.

The fix is two lines, but it isn’t free, and a few sharp edges are worth naming so they’re decisions instead of surprises.

  • optimizePackageImports costs build time. The rewrite is a real pass over your imports. You’re trading bundle size for build time, almost always worth it, but it’s a trade, not a freebie.
  • The dev bundle stays large. The transform runs in production builds by default, so pnpm dev will look heavy. That’s expected behavior, not a regression to chase.
  • It’s still experimental. As of Next.js 16 it lives under experimental and may move or change shape. Track the release notes, and don’t write code that assumes it has graduated until it has.
  • Deep imports are a semver risk. A per-icon path the library doesn’t treat as public API can break on a minor version bump, which is one more reason to prefer optimizePackageImports, since that only depends on the package’s public surface.
  • Don’t mix styles in one package. A per-icon deep import plus a { ... } barrel import from the same package still loads the barrel for the barrel lines. Pick one style per package and stick to it.

This discipline makes the whole class of regression attributable and preventable, instead of something you firefight after the bundle has already grown three releases in a row. The fix itself is small. The part worth your attention is knowing when it applies, and not trusting the tree-shaking myth.

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 underlying tree-shaking guide, and a tool to weigh any package before you install it.