Skip to main content
Why Your Next.js Client Bundle Is Bigger Than You Think: Barrel Files, optimizePackageImports, and the 'use client' Boundary

Why Your Next.js Client Bundle Is Bigger Than You Think: Barrel Files, optimizePackageImports, and the 'use client' Boundary

September 3, 2026
Frontend Engineering
9 min read

Key takeaways

• A barrel file is an index.ts whose only job is to re-export other modules. Importing one named symbol from a barrel makes the bundler resolve and evaluate every module that barrel names before it can decide what to delete.

• 'use client' marks a boundary, not a single file. Every module a client component imports, and every module those import in turn, is compiled into the browser bundle, so moving the directive down to the interactive leaf is usually the largest single lever on bundle size.

• experimental.optimizePackageImports in next.config.ts rewrites barrel imports into direct module paths at build time. Next.js already applies it to a built-in list of packages such as lucide-react and @mui/icons-material; any other package must be added explicitly.

• The "sideEffects": false field in a package.json is the signal that permits a bundler to delete unused re-exports. A package that ships CSS must use "sideEffects": ["*.css"] instead, or the stylesheet is tree-shaken away with everything else.

• next build prints a per-route table whose First Load JS column is the JavaScript a visitor downloads for that route, shared chunks included. That is the number to move, and @next/bundle-analyzer run with ANALYZE=true identifies which module moved it.

We found this the boring way. Reading the route table at the end of a next build, we noticed that a marketing page with three paragraphs and a signup form carried almost the same First Load JS as the dashboard. Nothing on that page was heavy. The shared chunk was heavy, because one small client component imported Button from a first-party barrel that re-exported the entire component library, icons and charts included.

What actually ends up in a Next.js client bundle?

Everything reachable from a 'use client' module ships to the browser, plus the React and Next.js runtime and the RSC payload for the route. Modules imported only by Server Components do not ship. That is the whole model, and it is why boundary placement matters more than any bundler flag.

The failure mode is a component that is 95% static markup with one interactive control, marked 'use client' at the top of the file. The directive is contagious downward: the date library, the icon set, and the validation schema that file imports all become client code, even though only one button needs a click handler. The fix is to keep the page a Server Component and extract the interactive part into its own client leaf.

One detail saves real bytes: TypeScript type imports are erased, but only when the compiler can tell they are types. Writing import type { User } from './models' guarantees erasure. Enabling verbatimModuleSyntax in tsconfig.json, available since TypeScript 5.0, makes the rule explicit, because a plain import is emitted as a runtime import even when every binding it names is a type.

Why do barrel files make bundles big and dev servers slow?

A barrel file forces the bundler to resolve every module it re-exports, even when one symbol is used. In a production build the bundler can often tree-shake the unused modules back out; in development it does not bother, which is why a barrel-heavy app compiles slowly on every cold route request even though the shipped bundle looks acceptable.

Tree shaking is also not guaranteed. It happens only when the bundler can prove a module has no side effects, meaning no top-level code that mutates anything outside itself. A single re-exported module that registers a polyfill, patches a prototype, or imports a stylesheet at the top level pins itself into the output, and every module it imports comes along.

For first-party barrels inside an app, the reliable fix is to import the module directly by path and delete the barrel, or to keep the barrel only for the small stable set of exports everything uses. An ESLint no-restricted-imports rule that bans the barrel path as an import source is what stopped ours from growing back.

What does experimental.optimizePackageImports actually do?

experimental.optimizePackageImports rewrites an import of a named symbol from a package into an import of the specific file inside that package which defines the symbol, at build time, so the bundler never walks the package barrel. Next.js applies it automatically to a maintained list of common offenders, including icon and UI packages such as lucide-react and @mui/icons-material. Packages outside that list, including first-party workspace packages in a monorepo, must be named explicitly in the experimental.optimizePackageImports array.

Two limits are worth knowing before relying on it. It works on package names, so an internal path alias such as @/components is not a candidate and is fixed by deleting the barrel instead. And it cannot rewrite a namespace import: import * as Icons from 'react-icons/fa' keeps the whole namespace live, because any property access on it is knowable only at runtime. The older modularizeImports option still exists for packages whose file layout needs a manual pattern.

How do we measure client bundle size without fooling ourselves?

Measure a production build and compare First Load JS per route between two builds of the same application. Development bundles are unminified, un-tree-shaken, and carry hot-reload machinery, so a number read from next dev says nothing about what users download.

Install @next/bundle-analyzer as a dev dependency and run the build with ANALYZE=true. The analyzer wraps the Next.js config and writes separate treemap reports for the client, Node.js, and Edge bundles. Open the client report, switch the size metric from parsed to gzipped, and read the largest rectangles, because parsed size misleads about what actually crosses the network.

The discipline that makes the measurement usable is one change per build. Deep imports, a config flag, and a dynamic import landed together tell you the total moved and nothing about which change earned it. We deliberately avoid quoting our own before-and-after numbers, because a bundle size delta is a fact about one dependency tree and does not transfer to another.

Which fix should we reach for?

• Direct module import — the bundler resolves one file instead of a whole barrel. Reach for it when the barrel is first-party, or the package is not in the optimize list.

• optimizePackageImports — named barrel imports are rewritten to deep paths at build time. Reach for it for a third-party package with a large barrel and many call sites.

• Moving 'use client' down — the imported subtree stops being client code at all. Reach for it when a mostly-static component was marked client for a single handler.

• next/dynamic — defers bytes to a second request while the total stays the same. Reach for it when code is genuinely not needed for the first paint or first interaction.

• Replacing the dependency — removes the bytes outright. Reach for it when the library duplicates a platform API such as Intl or URLPattern.

When should we use next/dynamic instead of shrinking an import?

Use next/dynamic when code is genuinely not needed for the first render, such as a rich text editor, a map, or a charting library behind a tab. It splits the module into its own chunk fetched on demand. It does not make an application smaller in total; it moves bytes later, and a chunk fetched at click time is a chunk the user waits on, which shows up as a worse Interaction to Next Paint rather than a worse Largest Contentful Paint.

In the App Router, ssr: false is allowed only inside a Client Component. Calling dynamic() with ssr: false from a Server Component is a build error, because a Server Component has no client render pass to opt out of. Always pass a loading placeholder with the same height as the real component, otherwise the deferred chunk buys a layout shift.

Does the server bundle size matter too?

Server bundle size matters for cold starts, not for the network. A serverless function must be loaded before it runs, and a large dependency graph makes that slower, so a package with native binaries or a big runtime is worth keeping out of the traced bundle. serverExternalPackages in next.config.ts, the stable name since Next.js 15 and previously experimental.serverComponentsExternalPackages, tells Next.js not to bundle a package and to require it natively at runtime. Packages such as sharp and most database drivers belong there.

The habit we ended up with is smaller than any of these techniques: read the route table on every build, and treat a jump in First Load JS the way a failing test is treated. The barrel that cost us a shared chunk arrived in a one-line pull request that no reviewer could reasonably have questioned. The build output would have flagged it the same day.

FAQ

**Q:** Does Turbopack tree-shake barrel files automatically, so this stops mattering?

**A:** A production build removes many unused re-exports regardless of bundler, but tree shaking still cannot remove a module with top-level side effects, and development builds do not tree-shake at all. Deep imports remain the fix that works in both modes.

**Q:** Is First Load JS the same as the size of a page's JavaScript?

**A:** No. First Load JS is the route's own chunk plus the shared chunks every route loads, which is why one bad import in a shared component raises the number on pages that never use it.

**Q:** Should we set "sideEffects": false in our own workspace package?

**A:** Yes, if no module in it does anything at import time. If any module imports CSS or registers a global, use the array form, for example "sideEffects": ["*.css"], so the bundler keeps those files and shakes the rest.

**Q:** Will deleting barrel files break a published package API?

**A:** It will if consumers import from the package root, so publish an exports map with subpath entries first and keep the root barrel for one deprecation cycle. Inside a private application there is no such contract, and the barrel can go immediately.