Feature-Based Organization and Layering Rules
Every React team eventually discovers the same failure mode. The app “worked fine” when
src/components held thirty files. Two years later it holds two hundred, and naming collisions
force prefixes like DashboardUserCard and SettingsUserCard not because the domain is exotic, but
because technical-type folders erase context. Hooks, services, and types float in global
buckets; the only way to understand a feature is to grep the repository and rebuild its graph in
your head. Onboarding slows because there is no obvious place to start. Refactors become risky
because moving one screen drags invisible dependencies across layers. Business logic leaks into
shared components “temporarily,” and temporary becomes permanent.
Feature-based organization is not a fad layout—it is a bet that colocation beats taxonomy. Everything that changes together for a user-visible capability should live together: UI, hooks that orchestrate that UI, the API client calls that feed it, and the types that describe its contracts. That does not mean every file must sit in one flat directory; it means the default question when adding code is “which feature owns this?” not “which technical category?”
Why technical-type structure breaks at scale
Consider the archetypal large-app anti-pattern:
src/
├── components/
├── hooks/
├── services/
└── types/
At small scale this reads as “clean separation.” At large scale it is a random graph generator.
A developer working on checkout touches components/CheckoutForm.tsx, hooks/useCart.ts,
services/cart.service.ts, and types/cart.types.ts—four directories away from each other, with no
filesystem signal that those files form one subsystem. Cross-cutting changes require mental
stitching. Code review becomes harder because diffs scatter across unrelated folder listings. Worse,
“shared” components absorb feature logic because the path of least resistance is to extend
components/DataTable.tsx with yet another prop instead of creating a feature-local wrapper.
Feature folders flip the primary axis. The unit of maintenance is the feature slice, not the file type.
A concrete feature-based layout
A structure that scales with teams looks more like this:
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── AuthGuard.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── services/
│ │ │ └── auth.service.ts
│ │ ├── types/
│ │ │ └── auth.types.ts
│ │ └── index.ts
│ ├── products/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── index.ts
│ └── cart/
│ ├── components/
│ └── index.ts
├── shared/
│ ├── components/
│ │ ├── Button.tsx
│ │ └── Modal.tsx
│ ├── hooks/
│ │ └── useLocalStorage.ts
│ └── utils/
│ └── formatting.ts
└── app/
├── layout.tsx
└── routes/
Notice what disappeared: there is no global components dump competing for names. Inside
features/auth, the internal breakdown by kind is fine—components, hooks, and so on—because the
scope is bounded. index.ts at the feature root is the public API. Everything else is
implementation detail. Consumers import features/auth (or a path alias like @/features/auth)
instead of reaching into features/auth/components/LoginForm.tsx. That single discipline makes
refactors inside the feature cheap: rename files, split modules, change internal hooks—external
imports stay stable.
shared/ is intentionally boring. It holds design-system primitives, generic utilities, and hooks
with no product opinion. If shared/ starts importing feature code, you have inverted the
dependency direction and recreated the old spaghetti through a side door.
app/ owns routing, layouts, and composition: which features appear on which routes, how providers
nest, and where loaders or RSC boundaries attach in a framework-specific way. It should stay thin.
Heavy domain logic belongs in features, not in route files that become accidental god objects.
Layering rules that prevent cycles
The dependency rules are simple and strict:
app/may importfeatures/*andshared/*. Nothing infeatures/orshared/may import fromapp/.features/*may import fromshared/*. Features must not import sibling features directly.shared/imports neitherfeatures/norapp/.
Why ban feature-to-feature imports? Because they are the fastest route to circular dependency
graphs and merge conflicts across team boundaries. When cart needs something from products,
the temptation is import { ProductSummary } from '../products'. That works until products needs
a cart badge. Now you have a cycle, and your bundler, test runner, and brain all pay interest
forever.
The fix is composition at the app layer—or a narrow shared contract. For example,
app/routes/checkout.tsx can import both CartPanel and ProductRecommendations and pass props or
render slots. Alternatively, promote a tiny, stable type or event bus to shared/ if it truly is
cross-cutting infrastructure. What you avoid is features becoming a mini-monolith of mutual imports.
TypeScript alone will not stop illegal imports; it happily resolves paths you should not use. That is where linting earns its keep.
Enforcing boundaries with ESLint
Treat architecture as code. eslint-plugin-import provides import/no-restricted-paths, which lets
you declare zones: pairs of from and target patterns where imports are forbidden. In flat
config, wire the plugin and add rules like:
// eslint.config.js
import importPlugin from "eslint-plugin-import";
export default [
{
files: ["src/**/*.{ts,tsx}"],
plugins: { import: importPlugin },
rules: {
"import/no-restricted-paths": [
"error",
{
zones: [
{
target: "./src/shared/**/*",
from: "./src/features/**/*",
message: "shared must not depend on features",
},
{
target: "./src/shared/**/*",
from: "./src/app/**/*",
message: "shared must not depend on app",
},
{
target: "./src/features/auth/**/*",
from: "./src/features/cart/**/*",
message: "features must not import sibling features (compose in app/)",
},
{
target: "./src/features/cart/**/*",
from: "./src/features/auth/**/*",
message: "features must not import sibling features (compose in app/)",
},
],
},
],
},
},
];
You will need to list feature pairs—or generate zones from a small script—if you have many slices. Teams that outgrow hand-maintained lists often adopt eslint-plugin-boundaries or dependency-cruiser for declarative architecture tests in CI. The mechanism matters less than the outcome: the build fails when someone shortcuts a layer.
When not to over-engineer features
Feature folders shine when multiple engineers touch the same repository, when domains are identifiable, and when releases are frequent enough that merge pain is real. For a single small product with one owner, a flatter structure can be faster—just do not let “temporary” flat folders ossify without migration. The layering rules still help even in smaller apps because they cost little and prevent the first cycle.
If your “features” are really just three screens, you might use a single modules/ or even
domain-named top-level folders without the full ceremony. The invariant to preserve is directed
acyclic imports and a clear shared kernel—not the exact folder spelling.
Feature-based organization is the foundation the next sections build on. Monorepos lift the same ideas across packages; Module Federation lifts them across deployable artifacts. Get the slice boundaries right inside one app, and those larger steps become easier instead of amplifying existing chaos.