You've probably seen them in every Next.js and React codebase: a folder full of components, utilities, or hooks with an index.ts that re-exports everything. It looks clean. It feels organized. But barrel files can hurt your bundle size, slow down your development server, and introduce circular dependency issues that are surprisingly hard to debug. Let me walk you through why this pattern, despite its appeal, often does more harm than good in production applications.
What barrel files are (and why they seem like a good idea)
A barrel file is typically an index.ts or index.tsx that re-exports multiple exports from a folder. Here's a common example:
// components/index.ts
export { Button } from './Button';
export { Card } from './Card';
export { Modal } from './Modal';
export { Tooltip } from './Tooltip';
export { Badge } from './Badge';This lets consumers import cleanly:
import { Button, Card, Modal } from '@/components';Instead of the more verbose:
import Button from '@/components/Button';
import Card from '@/components/Card';
import Modal from '@/components/Modal';The appeal is real. Your imports look tidy. You can reorganize internal folder structures without breaking consumer code. But this convenience comes at a hidden cost that compounds as your codebase grows.
They pull unused code into your bundles
Here's the core issue: when you import from a barrel file, bundlers like webpack and esbuild see it as a single entry point. Even if you only need Button, the barrel file imports all five components. If your bundler doesn't perform perfect tree-shaking (and most don't in all scenarios), you're shipping code you don't use.
Consider this real scenario from a trading platform I built. We had a massive utils/index.ts that re-exported 60+ utility functions:
// utils/index.ts (the problem)
export { formatCurrency } from './formatCurrency';
export { calculatePortfolioMetrics } from './calculatePortfolioMetrics';
export { fetchHistoricalData } from './fetchHistoricalData';
export { validateOrder } from './validateOrder';
export { parseWebSocketMessage } from './parseWebSocketMessage';
// ... 55 more exportsA single page imported only formatCurrency:
import { formatCurrency } from '@/utils';But webpack pulled in all 60 functions into that page's bundle. Some utilities had heavy dependencies—like the one that parsed WebSocket messages, which imported a 45KB protocol buffer library. Unused on that page. Now in the bundle anyway.
The fix is direct imports:
import { formatCurrency } from '@/utils/formatCurrency';Modern bundlers with ES modules understand this better. They see the import path points to a specific file and leave the rest alone. Your bundle shrinks. Not dramatically for one function, but multiply this across 200 imports in a mid-sized app and you're talking 20-40KB of dead code.
Slower dev server and test startup times
During development, your dev server watches files and rebuilds on changes. When you import from a barrel file, the bundler must resolve and parse not just your target file but every file the barrel re-exports.
I noticed this painfully on a CRM platform with a components/forms/index.ts that re-exported 30 form components. Each time I edited one form component, the dev server rebuild took 2-3 seconds instead of 300-400ms. Why? Because the bundler had to re-parse all 30 form files to check if anything changed in the barrel's exports.
With direct imports, the dev server only touches the specific file you changed:
// Before (barrel)
import { UserForm, OrderForm, PaymentForm } from '@/components/forms';
// After (direct)
import UserForm from '@/components/forms/UserForm';
import OrderForm from '@/components/forms/OrderForm';
import PaymentForm from '@/components/forms/PaymentForm';Same result. Faster rebuild. The difference multiplies across a team: if five developers each save 2 seconds per rebuild and do that 50 times a day, that's nearly 14 minutes of collective dev time wasted per day per person.
Test startup is even worse. Jest and Vitest must load and parse every module a barrel exports, even if your test file only needs one. A test suite with 150 test files importing from 5 barrel files means the test runner parses 300+ modules it might never execute. I've seen test suites take 45 seconds to start because of aggressive barrel file usage; switching to direct imports cut that to 12 seconds.
Circular import issues become harder to debug
Barrel files are magnets for circular dependencies. When you re-export from multiple modules in the same barrel, it's easy to create situations where module A imports from the barrel, the barrel imports module B, and module B imports from the barrel (or module A indirectly). JavaScript handles some circular dependencies gracefully, but the error messages are cryptic and the bugs are subtle.
Here's a real example from an e-commerce platform:
// hooks/index.ts
export { useCart } from './useCart';
export { useAuth } from './useAuth';
export { useProduct } from './useProduct';// hooks/useCart.ts
import { useAuth } from '@/hooks'; // imports from barrel
export const useCart = () => {
const { user } = useAuth();
// ...
};// hooks/useAuth.ts
import { useCart } from '@/hooks'; // imports from barrel, circular!
export const useAuth = () => {
// ...
};This creates a circular dependency: useCart → barrel → useAuth → barrel → useCart. The module might load (JavaScript tries hard), but you'll get undefined exports, stale closures, or runtime errors that appear inconsistently.
Direct imports make this obvious immediately:
// hooks/useCart.ts
import { useAuth } from './useAuth'; // direct import, circular error at parse time
export const useCart = () => {
const { user } = useAuth();
// ...
};The bundler fails loudly. You fix the design (maybe useCart shouldn't call useAuth, or they share a third hook). With barrels, you chase ghosts for an hour.
Import directly from source files
The solution is straightforward: import from the file that exports what you need, not from a barrel.
// Good
import Button from '@/components/Button';
import { useCart } from '@/hooks/useCart';
import { formatCurrency } from '@/utils/formatCurrency';// Avoid
import { Button } from '@/components';
import { useCart } from '@/hooks';
import { formatCurrency } from '@/utils';This is more verbose, yes. But verbosity is honest. It tells you exactly what you depend on. Path aliasing with TypeScript (baseUrl, paths in tsconfig.json or Next.js jsconfig.json) keeps long paths manageable without the barrel crutch:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["src/components/*"],
"@/hooks/*": ["src/hooks/*"],
"@/utils/*": ["src/utils/*"]
}
}
}Now your imports are clean without the hidden costs:
import Button from '@/components/Button';
import { useCart } from '@/hooks/useCart';
import { formatCurrency } from '@/utils/formatCurrency';In rare cases where you genuinely import many items from one folder (like a components library you publish), a barrel makes sense. But inside an application, the benefit of cleaner imports doesn't outweigh slower builds, larger bundles, and harder circular dependency debugging.
Common mistakes
- Using barrel files for everything. Not all folders need one. Utility folders, component folders, and hook folders especially don't. Reserve barrels for public APIs of internal packages only.
- Re-exporting deeply nested dependencies. If
components/Button/index.tsre-exports from./Button, and someone importsButton, that's fine. But if the barrel also re-exports from../sharedor../../utils, you're inviting circular dependencies. - Not checking bundle size impact. Run
npm run buildand inspect your bundle withnext/bundle-analyzerorwebpack-bundle-analyzer. You might be shocked how much unused code barrels pull in. This should be part of your PR review checklist. - Assuming your bundler will tree-shake perfectly. Modern bundlers are smart, but they're not magic. When in doubt, test it. Import from the barrel and check the built bundle. Unused code still there? Direct imports it is.
- Ignoring dev server performance. If your team complains builds are slow, check your barrel usage before optimizing webpack config. Often it's the bigger win.
Key takeaways
- Barrel files (index.ts re-exports) look tidy but often pull unused code into bundles, increasing your bundle size unnecessarily.
- They slow down dev server rebuilds and test startup because bundlers must parse all re-exported modules even if you only use one.
- Circular dependencies are harder to spot with barrels because the indirection hides the cycle until runtime.
- Import directly from source files instead:
import Button from '@/components/Button'instead ofimport { Button } from '@/components'. - Use path aliasing in
tsconfig.jsonto keep direct imports clean and readable. - Reserve barrel files for public APIs of published packages, not for internal application folders.
- Measure bundle size and dev server performance; you'll often find barrel files are the culprit.






Comments
Loading comments…