Organise features, components and utilities so a growing app stays easy to change.
Why it matters
A small React app can live in one src/ folder with a handful of files. A real product cannot. After a year it has dozens of pages, hundreds of components, several API clients and a team of people changing it at the same time. When nobody can tell where a piece of code belongs, people copy it instead of reusing it, imports turn into a tangle, and every change risks breaking something far away.
Structure is not about a perfect folder tree. It is about making three questions easy to answer: where does this code live, what is it allowed to depend on, and what can I delete when a feature goes away?
The core idea: group by feature, share deliberately
There are two common ways to organise a front-end.
By type puts all components in components/, all hooks in hooks/, all API calls in api/. It is fine for a tutorial, but a single feature such as "checkout" ends up spread across six folders.
By feature keeps everything one feature needs together, and only promotes code to a shared folder when a second feature actually needs it:
src/
app/ # routing, providers, app-wide layout
features/
checkout/
components/ # CheckoutForm.tsx, OrderSummary.tsx
hooks/ # useCart.ts
api.ts # fetchCart, placeOrder
types.ts # Cart, LineItem
index.ts # the feature's public API
courses/
...
components/ui/ # Button, Modal, Input: no business logic
lib/ # apiClient.ts, formatDate.ts, logger.ts
test/ # test helpers and fixtures
Most mid-size teams end up with a hybrid like this: features own their code, and a small shared layer holds design-system components (components/ui) and framework-free utilities (lib).
Rules that keep it healthy
Folders alone do not stop the tangle. A few dependency rules do most of the work:
- Dependencies point inward.
features/*may import fromcomponents/uiandlib.components/uiandlibmust never import from a feature. A Button that imports the cart is no longer reusable. - Features talk through their public API. Other code imports
features/checkout(itsindex.ts), notfeatures/checkout/hooks/useCart. That lets the checkout team move files without breaking everyone else. - UI components do not fetch. Keep data loading in hooks or page-level code and pass data down as props. Pure components are easier to test, reuse and show in a component library.
- Utilities are plain functions.
formatPrice(cents, currency)has no React, no fetch and no global state, so it can be unit tested in milliseconds. - Colocate tests and styles with the code they cover (or in a mirrored
test/folder if your framework would treat test files as routes, as Next.js does underpages/).
You can enforce rule 1 with lint rules such as import/no-restricted-paths in ESLint, so a reviewer does not have to catch it by eye.
Worked example: where does it go?
Your team adds a "Save for later" button to course cards.
- The heart icon button itself is generic. It goes in
components/ui/IconButton.tsx(or reuses one that is there). - The logic that calls
POST /api/bookmarksand updates the list is bookmark-specific. It goes infeatures/bookmarks/api.tsandfeatures/bookmarks/useBookmark.ts. - The course card composes both. It lives in
features/courses/components/CourseCard.tsxand importsuseBookmarkfromfeatures/bookmarks(the public API). - A helper that pluralises "1 lesson / 3 lessons" is framework-free. It goes in
lib/pluralize.tswith a test next to it.
Six months later the product team removes bookmarks. You delete features/bookmarks/, fix the one import in CourseCard, and you are done. That is the payoff.
Naming and file size
- One component per file, named after the component:
OrderSummary.tsxexportsOrderSummary. - Prefer named exports for components and utilities; they make renames and search easier.
- When a file passes a few hundred lines, look for a seam: a sub-component, a custom hook, or a pure helper you can pull out.
- Use path aliases (
@/lib/formatPrice) instead of../../../lib/formatPrice, configured intsconfig.jsonand your bundler.
Common mistakes
- A giant
utils.ts. Everything ends up there and everything imports it. Split by purpose:formatDate.ts,currency.ts,url.ts. - Premature sharing. Moving code to
components/uiafter one use creates abstractions that fit nobody. Wait for the second real use. - Business logic in shared UI. A
Buttonthat knows about user roles has quietly become a feature component. - Reorganising everything at once. Restructure feature by feature in small pull requests, not in one rename that conflicts with every open branch.
- Deep nesting. Four levels of folders make paths long and imports fragile. Two or three levels are usually enough.
Quick recap
- Group code by feature and promote it to a shared layer only when a second feature needs it.
- Keep dependencies pointing inward: features use
uiandlib, never the other way round. - Give each feature a small public API through an
index.ts. - Keep UI components presentational and utilities framework-free so both are easy to test.
- Enforce the rules with lint, and restructure in small steps.
Try it
Open the Do tab and answer the quiz on where code belongs. Then look at a project you have built, sketch its current tree, and mark one file that is in the wrong place.