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.
What a barrel export actually is
Section titled “What a barrel export actually 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 that all look like this:
export { default as Pencil } from './icons/pencil';export { default as Trash } from './icons/trash';// …1500 moreThe 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 import { Pencil } from 'lucide-react'; the import site 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.
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.
The two reaches
Section titled “The two reaches”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 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 just the entry barrel in one pass, which is cheaper than full tree-shaking, and it 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. The costs add up, though. It needs the library to expose typed deep paths. That path may not be a documented public API, so a minor version can move it, which is a semver risk. 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 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.
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.
The internal ui package is the same trap
Section titled “The internal ui package is the same trap”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:
- Keep the barrel re-export-only. No logic, and 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 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.
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 optimizePackageImportsThe decision frame
Section titled “The decision frame”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.
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.
That’s 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 keep an eye on it in the analyzer next lesson.
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.
Watch-outs and the cost of the fix
Section titled “Watch-outs and the cost of the fix”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.
optimizePackageImportscosts 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 devwill look heavy. That’s expected behavior, not a regression to chase. - It’s still experimental. As of Next.js 16 it lives under
experimentaland 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.
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 underlying tree-shaking guide, and a tool to weigh any package before you install it.