Headline: One
'use client'directive pulls that file's entire import graph into the browser bundle, and a barrelindex.tsre-exporting two hundred modules is how that graph gets large without anyone noticing. The changes that actually moved my First Load JS were importing modules by their real path instead of through a barrel, enablingexperimental.optimizePackageImports, and pushing the'use client'boundary down to the leaf that needs it.
Key takeaways
- A barrel file is an
index.tswhose 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. -
experimental.optimizePackageImportsinnext.config.tsrewrites barrel imports into direct module paths at build time. Next.js already applies it to a built-in list includinglucide-reactand@mui/icons-material. -
"sideEffects": falsein a package'spackage.jsonpermits a bundler to delete unused re-exports. A package that ships CSS must use"sideEffects": ["*.css"]instead, or the stylesheet is tree-shaken away. -
next buildprints a per-route table whose First Load JS column is the JavaScript a visitor downloads for that route, shared chunks included.@next/bundle-analyzerrun withANALYZE=truetells you which module moved it.
I did not go looking for this. I was reading the route table at the end of a next build and 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.
// app/dashboard/page.tsx — Server Component: these imports never reach the browser
import { buildReport } from '@/lib/report'; // stays on the server
import { Chart } from './chart'; // client leaf, below
export default async function Page() {
const data = await buildReport();
return <Chart data={data} />;
}
// app/dashboard/chart.tsx
'use client';
import { LineChart } from 'heavy-charts'; // only this one ships
One detail saves real bytes: TypeScript type imports are erased, but only if the compiler can tell they are types. Writing import type { User } from './models' guarantees erasure. Turning on verbatimModuleSyntax in tsconfig.json, available since TypeScript 5.0, makes the rule explicit — 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 you use one symbol. In a production build the bundler can often tree-shake the unused ones 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 fine.
Tree shaking is also not guaranteed. It only happens when the bundler can prove a module has no side effects — 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 any module it imports comes along.
// components/index.ts — the barrel
export * from './button';
export * from './modal';
export * from './data-table'; // ...and 197 more
// a client component
'use client';
import { Button } from '@/components'; // resolves 200 modules
import { Button } from '@/components/button'; // resolves one
For first-party barrels inside an app, the reliable fix is the second line: import the module directly and delete the barrel, or keep the barrel only for the small stable set of things everything uses. An ESLint no-restricted-imports rule that bans @/components as an import source is what stopped the barrel from growing back in my repo.
What does experimental.optimizePackageImports actually do?
experimental.optimizePackageImports rewrites import { X } from 'pkg' into an import of the specific file inside pkg that defines X, at build time, so the bundler never walks the package's barrel. Next.js applies it automatically to a maintained list of common offenders. Packages outside that list, including your own workspace packages in a monorepo, need to be named explicitly.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ['@acme/ui', 'react-icons', 'lodash-es'],
},
serverExternalPackages: ['sharp'],
};
export default nextConfig;
Two limits are worth knowing before you rely on it. It works on package names, so an internal path alias like @/components is not a candidate — that one you fix by deleting the barrel. 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 only knowable at runtime. The older modularizeImports option still exists for packages whose file layout needs a manual pattern.
How do I measure client bundle size without fooling myself?
Measure a production build and compare First Load JS per route between two builds of the same app. Development bundles are unminified, un-tree-shaken, and carry HMR machinery, so a number read from next dev means nothing about what users download.
npm i -D @next/bundle-analyzer
ANALYZE=true npm run build
@next/bundle-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 — parsed size misleads about what crosses the network. The rule: change one thing per build. Deep imports, a config flag, and a dynamic import landed together will tell you the total moved and nothing about which change earned it.
I am deliberately not quoting my own before-and-after numbers, because a bundle size delta is a fact about one dependency tree and does not transfer to yours.
Which fix should I reach for?
| Approach | What it changes | Reach for it when |
|---|---|---|
| Direct module import | Bundler resolves one file instead of a whole barrel | The barrel is first-party or the package is not in the optimize list |
optimizePackageImports |
Named barrel imports rewritten to deep paths at build time | A third-party package with a large barrel and many call sites |
Move 'use client' down |
The imported subtree stops being client code at all | A mostly-static component was marked client for one handler |
next/dynamic |
Defers bytes to a second request; total stays the same | Code genuinely not needed for first paint or first interaction |
| Replace the dependency | Removes the bytes outright | The library duplicates a platform API such as Intl
|
When should I use next/dynamic instead of shrinking an import?
Use next/dynamic when the code is genuinely not needed for the first render — a rich text editor, a map, a charting library behind a tab. 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.
'use client';
import dynamic from 'next/dynamic';
const Editor = dynamic(() => import('./editor'), {
ssr: false,
loading: () => <div className="h-64 animate-pulse rounded bg-muted" />,
});
In the App Router, ssr: false is only allowed 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, or the deferred chunk buys you a layout shift.
Does the server bundle size matter too?
It matters for cold starts, not for the network. A serverless function has to be loaded before it runs, and a large dependency graph makes that slower. serverExternalPackages in next.config.ts — the stable name since Next.js 15, previously experimental.serverComponentsExternalPackages — tells Next.js not to bundle a package and to require it natively at runtime instead. Packages like sharp and most database drivers belong there.
The habit I 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 same way you treat a failing test. The barrel that cost me a shared chunk was added in a one-line pull request that nobody could have reviewed into suspicion. 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 the 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 I set "sideEffects": false in my 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 my public 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 app there is no such contract, and the barrel can go immediately.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)