A Structure That Snaps Into Place

React stays hands-off about file organization, so every team ends up inventing its own system. After years of trial and error, a repeatable pattern has emerged that balances clean imports, tidy editor tabs, and logical grouping. It hinges on three decisions: one directory per component, a forwarding index file, and separation of component-specific code from shared code.

Priorities That Shape the Layout

Three practical concerns drive this file layout. Imports should stay short and stable—no hunting through nested folders to reference a component. When editing, the tab bar shouldn’t fill with same-named index files, forcing you to check the folder path to tell them apart. And organization should follow function over feature, with dedicated directories for components, hooks, helpers, and the rest.

Complex components inevitably pull in companion files: smaller sub-components, helper functions, custom hooks, and shared constants. Take FileViewer as an example. That single component ships with FileContent.tsx for syntax-highlighted output, Sidebar.tsx, Directory.tsx, File.tsx, plus FileViewer.helpers.ts for tree traversal and FileViewer.types.ts for shared TypeScript definitions. None of that needs to clutter the global namespace; it only matters when you’re actively working on the viewer itself.

Component Folders With a Redirect

The trick is to wrap each component in its own directory and insert a thin index.ts file that points to the real implementation:

// src/components/FileViewer/index.ts
export { FileViewer as default } from './FileViewer';

That file is a deliberate redirection. The bundler sees an import for the directory src/components/FileViewer, finds index.ts, and follows the path to ./FileViewer.tsx. It borrows the convention from web servers that resolve a directory to its index file.

Keeping the actual component logic in FileViewer.tsx rather than index.ts keeps file names descriptive everywhere. Imports stay short—import FileViewer from '@/components/FileViewer'—without drilling into subfolders. The editor still shows FileViewer.tsx in the tab, not a parade of anonymous index.ts files.

Hooks, Helpers, and the Shared Layer

A hook that belongs to a single component sits in that component’s folder. Once a hook earns reuse across many places, it graduates to src/hooks, where this blog keeps roughly fifty generalized utilities.

Functions that solve project-specific problems—sorting blog categories or formatting their display names—live under src/helpers. A helper that starts life inside a component might migrate there if the need becomes cross-cutting.

There’s also a deliberate split between helpers and utilities. Helpers are opinions about a particular project; they’d be nonsense in another codebase. Utilities are the opposite: abstract, reusable functions that could live anywhere. Random array picking, cursor placement in a text input, or Cartesian distance for animations are all examples worth keeping in a portable src/utils.ts file.

Ultimately, that utility file travels from project to project. Publishing it to a package registry is an option, but the friction outweighs the benefit when copy-pasting works well enough. Your experiences may differ on that point, but the balance for small-to-medium codebases is rarely worth the tooling overhead.

Constants and Pages Variations

App-wide values—colors, breakpoints, public keys—settle in a top-level constants.ts. That keeps style tokens and API identifiers in a single, consistent spot.

The one major gap is “pages.” That choice depends entirely on the framework. With a tool like Create React App there are no pages, only components. Under Next.js, a separate /src/pages directory holds the route-level components, each responsible for the rough shape of a particular URL.

What This Structure Costs You

No folder convention is free. Here's the honest accounting for the approach outlined above.

Blame the Barrel Files

The pattern leans on barrel files: each component directory contains an index.ts whose only job is re-exporting siblings. The theoretical downside is extra work for the bundler. In practice, it's rarely something you should lose sleep over.

To put it in perspective: this blog has roughly 1,200 TS/JS files, and about 15% are barrels. The node_modules directory alone holds ~50k TS/JS files. The bundler spends almost all of its effort on third-party code; less than 1% of the modules it encounters are hand-written barrels.

The exception is if you're publishing an NPM package, particularly a library with hundreds of individual modules like lodash. Then barrels can become a real issue. But for web applications, you'd need tens of thousands of files and millions of lines of code before this matters—a scale almost no project reaches. Worrying about it now is a premature optimization. And if your app ever does grow that large, an LLM agent can do the tedious refactor.

The Boilerplate Tax

Creating a component means generating three things: a new Widget/ directory, a Widget/Widget.tsx file, and the Widget/index.ts forwarder. That's a lot of repetitive setup for every single component.

Except you don't have to do it by hand. The author built an NPM package, new-component, that automates the whole process. Running it in the terminal produces all the boilerplate, including the basic component structure. It completely neutralizes this drawback in practice.

The package is available if you want it, though it's not actively maintained. Fork it to adjust it to your own conventions.

App Router Friction

After migrating this blog to Next's App Router, the new-component package started throwing errors. The problem is the barrel file's structure:

export * from './FileViewer';
export { default } from './FileViewer';

The App Router bundler dislikes the * export because it re-exports everything, including the default export, which gets duplicated. Removing the wildcard import fixes the error but creates a second one during build:

**Type error:** Module "/components/FileViewer/index" has no default export.

The only workaround found is dropping the wildcard entirely:

export { default } from './FileViewer';

That's less convenient because nothing else gets exported. If you also need named exports, you have to remember to add them by hand:

export { default, SomethingElse } from './FileViewer';

The author hasn't had time to investigate further. If you want to use new-component with the App Router, fork it and delete this line from the source.

Function vs. Feature: A Real-World Comparison

Broadly, code organization falls into two camps: by function (components, hooks, helpers) or by feature (search, users, admin).

A feature-based layout looks like this:

src/
├── base/
│   └── components/
│       ├── Button.tsx
│       ├── Dropdown.tsx
│       ├── Heading.tsx
│       └── Input.tsx
├── search/
│   ├── components/
│   │   ├── SearchInput.tsx
│   │   └── SearchResults.tsx
│   └── search.helpers.ts
└── users/
    ├── components/
    │   ├── AuthPage.tsx
    │   ├── ForgotPasswordForm.tsx
    │   └── LoginForm.tsx
    └── use-user.ts

That approach has genuine charms. It cleanly separates low-level reusable "component library" pieces from high-level template-style views. It also makes the app's overall shape easier to grasp at a glance.

But here's the catch: real life doesn't segment so neatly, and categorization is genuinely hard.

Every new component forces a judgment call about which feature owns it. A component for searching users—is that "search" or "users"? Boundaries blur fast, and different developers land on different answers.

Starting work on a feature means hunting for files that may not be where you expect. Each developer carries their own mental model of the structure, and you'll spend time acclimating to theirs. The friction compounds as products evolve: the feature boundaries you draw today rarely match the product tomorrow. Moving and renaming files to re-categorize everything is a massive undertaking—one that realistically never gets done. Teams are busy, half-finished PRs touch files that would disappear in a reorganization, and managing those conflicts is a nightmare.

So the distance between product features and code features drifts apart over time. The codebase ends up organized around a product that no longer exists, and everyone has to memorize where things go. The boundaries become arbitrary at best, misleading at worst. It's possible to avoid that worst case, but it's a lot of extra work for little payoff.

But doesn't the function-based alternative devolve into chaos? Larger projects can easily hold thousands of components sitting side-by-side in src/components. In practice, though, that's not a problem. Nobody scans the full list looking for a file. Developers hop between files using IDE shortcuts—in VS Code, you open a dialog and type the first few letters of the filename. The sheer count doesn't matter.

Clean Up Imports With Bundle Aliases

Modern bundlers like Webpack support aliases, which map a global name to a specific file or directory:

// This:
import { sortCategories } from '../../helpers/category.helpers';

// ...turns into this:
import { sortCategories } from '@/helpers/category.helpers';

The idea is simple: alias @/helpers to the /src/helpers directory. When the bundler sees @/helpers, it substitutes a relative path automatically. This turns unwieldy relative imports like ../../helpers into clean absolute ones like @/helpers. You never count ../ levels again, and moving files no longer breaks import paths.

Implementation details depend on your meta-framework, so it's beyond the scope here. The Webpack documentation is the right place to learn more.

Exploring the FileViewer Component

Curious how the FileViewer component at the top works? The honest answer is that it's not the author's best code, but rendering a recursive structure with React posed some interesting challenges.

You can dig into it directly: the FileViewer component lets you explore its own source. Not all the context is provided, but it gives a solid picture of the mechanics.

Last updated December 3rd, 2025.