You just finished a feature. Nothing dramatic. A small screen, a form, a couple of helpers. Now you’re sitting there holding the leftovers. A custom hook. A little utility function. A type or two. A stylesheet. And you have to decide where each one goes.
In the old codebase, that was a real decision. The hook could go in the global hooks/ folder. The helper could go in utils/. The type in types/. The style next to something, somewhere. Every file you wrote came with a small fork in the road, and everyone picked a slightly different branch.
So the code for one feature ended up smeared across five different top-level folders. To understand the catalog page you opened components/, then hooks/, then utils/, then types/, and none of those folders was called catalog. The feature existed. It just didn’t live anywhere. It was scattered.
Organizing by type sounds tidy
The instinct behind the old layout is a good one. Put all the components together. All the hooks together. All the utilities together. It looks like a library. It looks organized.
It reads fine right up until you have more than a handful of features. Then the shared folders turn into drawers you’re afraid to open. utils/ has two hundred functions in it and you have no idea which screens use which. hooks/ is the same. You want to change one helper and you can’t tell what you’ll break, because anything is allowed to import anything, and over a couple of years, everything did.
That’s the real problem with grouping by type. It puts unrelated things next to each other and invites them to get tangled. A helper written for one screen gets quietly reused by three more. You change it for the first screen, and two screens you’d never heard of break in a way nobody catches until a customer does.
Shared-by-default is how a codebase slowly ties itself in knots.
One folder per feature
So a couple of years ago I wrote it down as a decision. Stop organizing by type. Organize by feature.
Every feature gets one folder. Everything that feature needs lives inside it. Its components, its hooks, its API calls, its types, its helpers, its constants, its styles, its tests. All of it, in one place, named after the thing it actually is.
A feature folder looks like this:
CustomHomePage/
├── __tests__/ // tests for this feature
├── components/ // components used only here
├── hooks/ // hooks used only here
├── layouts/ // layouts specific to this feature
├── api.ts // this feature's API calls
├── index.ts // the feature's main export
├── types.ts // this feature's types
├── utils.ts // this feature's helpers
├── constants.ts // this feature's constants
└── customHomePage.styles.ts // styles for the main component
Now the question that started this post has a boring answer. Where does the hook go? In the feature folder. Where does the helper go? In the feature folder. The type, the style, the test? Feature folder. You stop standing at a fork, because there isn’t one anymore. The default is always “next to the thing that uses it.”
And finding code gets just as boring. To understand the catalog, you open views/Catalog and it’s all there. You don’t go on a tour of six directories. The feature lives in one, and it’s named after itself.
Shared has to earn it
The root-level folders didn’t go away. We still have api/, hooks/, utils/, stores/, and the rest. They just changed jobs. They are now only for things that are genuinely used across more than one feature.
The rule we follow is small. Code starts local, inside its feature. It only moves up to a shared folder when a second feature actually needs it. Not when you have a hunch it might be reused someday. When it’s really needed, by real code, in a second place. Sharing too early is how the old junk drawers filled up in the first place, so we made “keep it local” the lazy, default choice and “make it shared” the deliberate one.
There’s a middle ground too, for the awkward case. Sometimes a bit of code is shared by a few parts of one big feature, but nothing outside it. That doesn’t belong in the global folders, and it doesn’t belong buried in one sub-part. So a feature can have its own shared/ folder for exactly that. Our CourseStore and Subscription features both have one. It keeps the “shared” honest: local until a real neighbor needs it, and no higher up the tree than it has to be.
One more small rule that trips people up: a feature’s own store lives with the feature, not in the root stores/. The root is for state the whole app leans on. Everything else stays home.
A convention nobody enforces is a suggestion
Here’s the part I got wrong the first time.
You can write all of this down. You can put it in a document, give it a nice diagram, and present it to the team. And it will hold for about a month. Then someone in a hurry imports a helper from another feature because it was right there and it worked, and the reviewer is busy that day, and now there’s one thread connecting two features that were supposed to be strangers. Do that fifty times and you’re back where you started, except now there’s a document claiming you’re not.
A rule that only lives in a document is a suggestion. To actually keep the boundaries, they have to be checked by something that doesn’t get tired or busy.
So we made the structure defend itself. A tool called dependency-cruiser runs on every pull request, and it knows the boundaries as real rules:
components cannot import from views
hooks cannot import from views
utils cannot import from views
views/A cannot import from views/B
Shared code is not allowed to reach down into a feature. One feature is not allowed to reach sideways into another. There’s a rule against circular imports for good measure. The moment someone crosses a line, the build stops and says no, right there in the pull request, before a human has to notice. The layout isn’t a promise we keep by being careful. It’s a fact the CI enforces whether we’re careful or not.
What gets easier once it sticks
None of this is the point on its own. The point is what it makes ordinary.
Changing code stops being a gamble. When a helper lives inside the feature that uses it, you already know who depends on it: this feature, and nobody else. You can rewrite it, rename it, or delete it outright without first tracing every import across the tree. The blast radius of a change is the folder you’re standing in, and you can see the whole folder at once.
And when something does break, you know where to look. A bug in the catalog lives in the catalog folder, because that’s where the catalog’s code lives. You’re not bouncing between six directories trying to work out which shared helper got handed the wrong value three features away. The stack trace points at a folder, and the folder holds the whole story.
Deleting code gets easy, which almost never happens anywhere. When a feature is retired, you delete its folder and you’re finished. There are no orphaned helpers left behind in utils/, no lonely hook that three screens still import for one line nobody understands. The feature arrived as one unit, and it leaves as one.
And two people can build at once without elbowing each other. Two features are two folders, so two engineers working on two things rarely touch the same files. The merge conflicts that used to bloom in the shared drawers, everyone editing the same utils.ts from opposite directions, mostly just stop.
The honest tradeoffs
This is not free, and pretending otherwise would be a lie.
You get some duplication. A tiny helper sometimes gets written twice in two features instead of shared once. That looks wasteful, and now and then it is. But a copied ten-line function is a much smaller problem than a shared one that secretly welds two features together. Duplication you can see. Coupling you can’t.
You also get more folders and more files. Someone new to the codebase, looking at a feature with its own little components and hooks and utils, might feel it’s a lot of ceremony for one screen. And there’s a short adjustment period where people have to unlearn the reflex to drop everything in a global folder.
I’ll take all of that. Those downsides are visible and shallow. The thing it prevents, a codebase quietly tying itself into a knot over three years, is the one that actually hurts.
The payoff is that it’s boring
The best thing I can say about this structure is that nothing interesting happens anymore.
A new engineer joins and, within a day, can guess where any piece of code lives, because there’s one rule and it’s the obvious one. A new AI coding assistant reads the repo and puts new files in the right place, because the right place is predictable. Code review stops including the phrase “actually, can you move this.” Where does this file go? It has exactly one answer, every time, and nobody has to think about it.
Put it next to the thing that uses it. Move it up only when something else truly needs it. Let the build enforce the rest.
That’s the whole idea. It’s not clever. It was never supposed to be.
