Hi, I'm Lianwen Wu, creator and maintainer of ReactUse. In my spare time, I also build some iOS apps. This talk is about a single import line. In a real Next.js admin app, we'll see how much less work the dev compiler does when one barrel import becomes a direct import.
Passage 20:15
Then we'll look at Next's own optimizePackageImports: how to use it, how it works, and why it can't help with your own index files. We'll also see what happens to tsc. Finally, how to lock the convention in with ESLint and AGENTS.md.
Notes for this slide end here. Use Next to continue.
Slide 02 / 15
A barrel file: one index.ts that re-exports
Passage 10:16
First, what is a barrel file? On the left there's no barrel: each component is imported by its full path. On the right we add components/index.ts. Every component is re-exported from there, and callers just write './components'.
Passage 20:16
That's convenient: callers don't care which file a component lives in. But index.ts is a module too. Import it, and your tools have to process everything it links to first. What it links to decides what that convenience costs.
Notes for this slide end here. Use Next to continue.
Slide 03 / 15
Case study: a workflow admin app
Passage 10:15
Here's the case: a Next.js App Router admin app for workflows. The home page lists workflows, the edit page edits a workflow's JSON config, and the layout renders a shared App shell with the nav bar. App only needs AppNav.
Passage 20:18
Under components there are shared components and their index. Under features/editor there's the editor, and MonacoSurface pulls in the full Monaco Editor and its Worker. Keep the card on the right in mind: from here on we only request the home page. We never open the edit page.
Let me switch to the code. This is the workflow app from the slide. The home page lists workflows, and the editor lives in a separate feature folder. I’m only going to open the home page.
First, the barrel version. I’m opening the home page, which only lists workflows. I won’t open the editor.
5. 回到终端,指向 ⏱ [barrel] compiled in … s 和 from monaco-editor。把这行留在屏幕上,记住实际秒数。这里看 compiled in,不把 GET / 的响应耗时当作编译耗时。
Say0:11
Here is the compilation time for the barrel version. Notice the Monaco files as well. We only opened the home page, but the compiler still processed the editor’s dependencies.
6. 等 barrel 完全结束后,浏览器打开 http://127.0.0.1:4302/。同样只访问首页,等 direct 编译结束。
Say0:09
Now the direct-import version. It has the same page and the same editor feature. The only change is the import path for AppNav.
7. 回终端并排对照 ⏱ [barrel] 和 ⏱ [direct] 两行的 compiled in … s。按现场实际秒数比较,可指着数字读;同时指出 direct 的 Monaco 文件数为 0。不要背 README 中的示例时间。
Say0:15
Here is the direct version’s compilation time. Compare it with the barrel result above. The compiler now processes zero Monaco files for this page. One import path changed, and the compilation has much less work to do.
The live server isn’t ready, so I’ll use the saved results from an earlier run of these same two versions. These are recorded compilation times, in milliseconds. We can compare the Monaco file counts here too.
Notes for this slide end here. Use Next to continue.
Slide 04 / 15
The editor slipped into the public entry
Passage 10:18
Open components/index.ts. The first two lines are the nav bar and a status badge. Fine. The third line: when someone added the editor feature, they also added WorkflowEditor to this public entry. It's one more re-export, no big deal.
Passage 20:06
But now App, which only wants the nav bar, imports an entry that has a whole heavy editor hanging off it.
Notes for this slide end here. Use Next to continue.
Slide 05 / 15
Requesting / walks the imports to Monaco
Passage 10:11
Let's follow the imports to explain what we just saw. The home page needs the layout. The layout needs App. App takes the nav bar from the components index.
Passage 20:16
That index re-exports features/editor, which leads to WorkflowEditor, then MonacoSurface, and finally the full monaco-editor package. The dev compiler doesn't stop just because App only used AppNav. That explains the difference we saw in the demo.
Passage 30:03
Now let’s look at how I measured it more carefully.
Notes for this slide end here. Use Next to continue.
Slide 06 / 15
Experiment: two versions, one line apart
Passage 10:12
We just compared both versions live. For the results on the next slide, I repeated the same comparison under controlled conditions. Two source snapshots, compared file by file.
Passage 20:14
The only difference is this line in App: the red line goes through the barrel, the green line points straight at the AppNav file. Pages, layout, the editor, and the Worker config are identical. Both run Next 16's next dev with webpack.
Passage 30:05
Each run deletes .next, starts a fresh process, and requests the home page once.
Passage 40:15
We measure three things: how many Monaco files are in the compiled module graph, the compile time for that request from webpack stats, and the peak memory of the Next process tree, sampled every 50 milliseconds. Three alternating runs per version, and we take the median.
Notes for this slide end here. Use Next to continue.
Slide 07 / 15
Results: the same request to /
Passage 10:18
All the results on one slide. First, Monaco files compiled: 1,348 with the barrel, zero with the direct import, and the edit page was never opened. Second, compile time for the home page: 12.33 seconds versus 2.47, about a fifth.
Passage 20:15
Third, peak memory of the process tree: 1,686 versus 850 MiB, roughly half. A note on that number: it includes the framework, the compiler and SSR, so it's not the memory of Monaco objects themselves.
Passage 30:09
The three-run ranges were 11.33 to 12.40 seconds and 2.19 to 2.95 seconds, so this isn't a cherry-picked run.
Passage 40:13
On average: these 1,348 files are about 25 MiB of source. Every extra 10 MiB of source that enters the compile costs about 3.9 seconds and 330 MiB.
Notes for this slide end here. Use Next to continue.
Slide 08 / 15
For barrels in dependencies, Next can help
Passage 10:12
You might ask: doesn't Next have optimizePackageImports? First, how to use it. Add your component library's package name to experimental.optimizePackageImports in next.config.
Passage 20:17
Next 16 already enables it for 75 common packages, like lucide-react, antd, @mui/material, date-fns, recharts and react-icons. The effect is big. We built a 50 MiB component library and imported one component from the package root.
Passage 30:20
By default, compiling the home page took 43.7 seconds. With the config, 3.1 seconds, the same as a hand-written subpath import. But note: it only matches package names in node_modules. The './components' folder in our case is part of the project, so it's left alone.
Notes for this slide end here. Use Next to continue.
Slide 09 / 15
How it works: rewrite to direct imports
Passage 10:21
How does it work? It turns your import into direct imports at compile time. You write import { Button, Table } from '@acme/ui'. What Next actually compiles is equivalent to importing button.js and table.js directly. Three steps. One: match the package name.
Passage 20:18
Only listed packages are handled, and SWC rewrites the import into a special request that carries the names you asked for. Two: next-barrel-loader reads the package's entry file once and builds an export map: Button comes from ./dist/button.js, Table from ./dist/table.js.
Passage 30:17
Three: only those two files are compiled. The other few hundred exports never enter the compile. Two limits: export star is followed level by level, and if the entry file contains anything other than re-exports, it's left as is. So this is for dependencies.
Passage 40:04
For your own entries, the most direct fix is still a narrower import.
Notes for this slide end here. Use Next to continue.
Slide 10 / 15
tsc: no difference in CI, a big one on save
Passage 10:19
Now tsc. Just the conclusion here. A full tsc --noEmit in CI checks 5,354 files with either version: 14.4 seconds versus 15.0, no difference, because tsconfig already includes all the source. The difference shows up during development.
Passage 20:14
Under tsc --watch, add one comment to one component: with the barrel, 4,162 files are rechecked and it takes 11.7 seconds. With direct imports, 110 files, 0.9 seconds.
Passage 30:14
Incremental checking rechecks every importer of a changed file, and a barrel makes every page that imports from the UI package depend on the whole UI package. TypeScript 7, the native version, is much faster, but it rechecks exactly the same files.
Notes for this slide end here. Use Next to continue.
Slide 11 / 15
Another cost: a feature that imports itself
Passage 10:16
Barrels have a second common problem, unrelated to performance. Inside features/editor, WorkflowEditor needs editorOptions and takes it from its own folder's index. But that index re-exports WorkflowEditor. So we have a cycle.
Passage 20:07
It may not fail right away, but once an entry meant for outside callers is used by sibling files, it easily loops back on itself.
Notes for this slide end here. Use Next to continue.
Slide 12 / 15
The fix: import the neighboring options.ts
Passage 10:09
The fix is one line: import from the neighboring './options' instead of './index'. Internal files import the definition directly.
Notes for this slide end here. Use Next to continue.
Slide 13 / 15
Catch it with ESLint
Passage 10:17
How do we prevent all this? Let ESLint do it. no-barrel-files blocks new index re-exports. import/no-cycle fails on import cycles. no-restricted-imports blocks root imports across packages and tells you to use a subpath.
Passage 20:10
To make them actually work, set up the TypeScript parser and resolver for .ts, .tsx and your path aliases. Run the same command in CI.
Notes for this slide end here. Use Next to continue.
Slide 14 / 15
Write it into AGENTS.md / CLAUDE.md
Passage 10:15
A lot of code is written by AI now, so put the same rules into AGENTS.md or CLAUDE.md. A few lines are enough: no index re-exports in apps or internal packages, import the defining file, use subpaths across packages, and run lint when done.
Passage 20:17
On the package side, provide subpaths through exports in package.json. One wildcard line does it, and callers write '@acme/ui/button'. Ideally, internal packages don't keep a root index at all: as long as it exists, consumers can still import the barrel.
Notes for this slide end here. Use Next to continue.
Slide 15 / 15
Thank you
Passage 10:19
To wrap up. One convenient import can make your tools do a lot of extra work. In this case, compiling the home page went from 12 seconds to 2.5, and peak memory was cut in half. On average, every 10 MiB of extra source cost about 4 seconds and 330 MiB.
Passage 20:17
For dependencies, optimizePackageImports rewrites imports into direct ones at compile time, but it can't reach your own index files. tsc shows no difference in CI, but under watch, changing one component rechecks 4,162 files instead of 110.
Passage 30:10
In practice: no index files in apps and internal packages, subpath exports across packages, and ESLint plus AGENTS.md to keep it that way. Thank you.