Skip to main content

Feature-Based vs Flat Folder Structure: React

As React projects grow beyond 10,000 lines, type-based structure (components/, services/, pages/) stops scaling. Components drift from their domain. A button used by the auth feature sits in a shared components/ folder, isolated from auth-specific logic. Refactoring becomes a guessing game: change the button, and you discover it breaks a payment flow halfway across the codebase. Feature-based structure solves this by grouping all code for one domain—pages, components, services, hooks—into a single folder. This article walks you through both patterns, shows why feature-based scales, and teaches you to migrate without breaking production.

Type-Based vs Feature-Based: The Core Difference

Type-based structure organizes by what code is, not what it does:

src/
components/ # all React components
hooks/ # all custom hooks
services/ # all business logic
pages/ # all routes

This works for apps under 15,000 lines. A developer learning the codebase sees one folder per type. But as features grow, it creates a false sense of organization. A payment feature might have components in components/Payment/, logic in services/paymentService.js, hooks scattered, and pages in pages/Payment/. It's not co-located; you must jump between folders to understand one feature.

Feature-based structure groups everything for one domain together:

src/
features/
auth/
components/
LoginForm.jsx
SignUpForm.jsx
hooks/
useAuth.js
services/
authService.js
pages/
LoginPage.jsx
SignUpPage.jsx
payments/
components/
PaymentForm.jsx
Invoice.jsx
hooks/
usePayment.js
services/
paymentService.js
stripeIntegration.js
pages/
PaymentPage.jsx
shared/
components/
Button.jsx
Modal.jsx
hooks/
useLocalStorage.js
utils/
formatDate.js

A developer working on payments touches only the features/payments/ folder. The code for one feature is together. New team members onboard faster because domain boundaries are obvious.

When Type-Based Wins

Type-based structure is best for:

  • Tiny projects: landing pages, dashboards under 3,000 lines. No feature division exists yet.
  • Shared libraries: utility libraries with no routes. lib/hooks/, lib/utils/, lib/types/ makes sense because there are no features.
  • UI-first apps: design systems, component libraries focused on isolated components. Group all buttons, inputs, modals in one components/ folder so designers can audit every variant.

If your entire app is "show a form, validate it, submit to an API," type-based structure is simple and sufficient. When that's the reality, don't over-engineer.

When Feature-Based Scales

Feature-based structure wins for:

  • Multi-domain apps (10K+ lines): e-commerce sites with auth, product search, cart, checkout, orders, reviews—each a domain.
  • Team-based development: teams own features. The payments team owns features/payments/. No coordination overhead; imports are local.
  • Independent deploy: feature folders can become separate packages or microservices with minimal refactoring.
  • Long-lived codebases: where maintenance and evolution matter more than initial velocity.

Spotify, Figma, and Meta—teams shipping hundreds of thousands of lines of JavaScript—use feature-based structure. It's the de facto standard at scale.

The Feature-Based Directory in Detail

Here's how to structure a feature folder as your codebase grows:

src/
features/
auth/
__tests__/ # tests for this feature only
useAuth.test.js
loginFlow.test.js
components/ # auth-specific components
LoginForm.jsx # never used elsewhere
OTPInput.jsx
hooks/
useAuth.js # auth state management
useAuthRedirect.js # conditional redirect logic
services/
authService.js # API calls: login, signup, logout
tokenManager.js # JWT handling, refresh logic
pages/
LoginPage.jsx # route handler: /login
SignUpPage.jsx # route handler: /signup
types/ # TypeScript types for this feature
user.ts
index.js # barrel file (article 9 covers this)
payments/
__tests__/
components/
hooks/
services/
pages/
types/
index.js
shared/ # components, hooks, utils TRULY used by multiple features
components/
Button.jsx
Modal.jsx
hooks/
useDebounce.js
utils/
formatDate.js
validation.js

Each feature is self-contained. If payments depends on auth, it imports from features/auth, not from services/authService.js. This makes the dependency graph explicit and acyclic: payments depends on auth, but auth never depends on payments.

Import Rules for Feature-Based Structure

Without import discipline, feature-based structure collapses into chaos. Enforce these rules via ESLint:

Rule 1: Features never import from other feature pages. A page is a route entry point and never reused.

// BAD: importing a page from another feature
// features/cart/components/CartSummary.jsx
import { PaymentPage } from '../../payments/pages';

// GOOD: import a hook or service
import { usePayment } from '../../payments/hooks';

Rule 2: Features can import from other features' components, hooks, and services, but not pages.

// GOOD: auth components can use shared Button
import { Button } from '../shared/components';

// GOOD: payment service can use auth service
import { authService } from '../auth/services';

// BAD: payment component should not reach into auth pages
import { SignUpPage } from '../auth/pages';

Rule 3: Shared feature components/hooks/utils can import from other shared exports, but not from feature-specific code. The shared/ folder is a dumping ground unless you enforce this.

// BAD: shared Button depends on auth-specific logic
// src/features/shared/components/Button.jsx
import { useAuth } from '../auth/hooks';

// GOOD: shared Button is independent
export function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}

Enforce these with ESLint:

// .eslintrc.json
{
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./src/features/*/pages",
"from": "./src/features/*/components",
"message": "Features cannot import from other feature pages"
},
{
"target": "./src/features/shared",
"from": "./src/features/[^shared]*",
"message": "Shared cannot depend on feature-specific code"
}
]
}
]
}
}

Migrating from Type-Based to Feature-Based

Refactoring a 20,000-line type-based app to feature-based takes discipline but is doable without breaking production. Here's a proven approach:

Step 1: Identify features. Look at your pages. Each page (or group of related pages) is likely a feature. Auth, dashboard, payments, settings—these are features.

Features identified:
- auth (login, signup, password reset)
- dashboard (home, analytics, insights)
- profile (settings, preferences, account)
- admin (user management, moderation)

Step 2: Create feature folders incrementally. Don't refactor everything at once. Pick the smallest, most stable feature (often auth) and move it first.

# Move auth to feature-based structure
mkdir -p src/features/auth/{components,hooks,services,pages}

# Move auth files
mv src/components/LoginForm.jsx src/features/auth/components/
mv src/components/SignUpForm.jsx src/features/auth/components/
mv src/hooks/useAuth.js src/features/auth/hooks/
mv src/services/authService.js src/features/auth/services/
mv src/pages/Login.jsx src/features/auth/pages/LoginPage.jsx

Step 3: Update imports incrementally. After moving one feature, update imports in the old location. Use a global find-replace to catch 80% of it, then test and fix the rest.

// Before: src/pages/Dashboard.jsx
import { useAuth } from '../hooks/useAuth';
import LoginForm from '../components/LoginForm';

// After: src/pages/Dashboard.jsx
import { useAuth } from '../features/auth/hooks';
import LoginForm from '../features/auth/components';

Step 4: Test thoroughly. Run your test suite. Manual test the feature you moved. If it works, ship. If it breaks, revert and debug.

Step 5: Move the next feature. Repeat until all features are in features/. This takes 1–2 weeks for a 30K-line app if done carefully. Rushing here causes bugs.

Step 6: Clean up shared folders. Once all features are moved, audit what remains in the old components/, hooks/, services/ folders. Move truly shared code to features/shared/. Delete empty old folders.

A team working on a real 18,000-line React app did this in three iterations (3 weeks total), shipping each feature migration without downtime. The key was testing after each move and keeping commits small.

Hybrid Approach: When to Mix Both

Some teams use a hybrid: type-based for global utilities, feature-based for domain logic.

src/
components/ # low-level shared components (Button, Modal, Input)
hooks/ # low-level shared hooks (useDebounce, useLocalStorage)
utils/ # pure utilities (formatDate, validators)
features/ # high-level features
auth/
payments/
dashboard/

This works well for 15K–50K line apps where you have a few truly shared, reused components and many domain-specific features. The key: components/ and hooks/ at the root are for reused, framework-agnostic code. Feature-specific stuff lives in features/.

Key Takeaways

  • Type-based structure works for apps under 10K lines; feature-based scales to 100K+.
  • Features group all code for one domain: pages, components, hooks, services, tests.
  • Feature-based structure makes team ownership clear and independent deployment easier.
  • Enforce import rules to keep the dependency graph acyclic: features import from shared, shared never imports from features.
  • Migrate incrementally: pick one feature, move it, test, ship, then move the next.
  • Use a hybrid approach for large apps: type-based for low-level shared code, feature-based for domain logic.

Frequently Asked Questions

How do I decide if code is shared or feature-specific?

Ask: "Is this used by two or more features?" If yes, it's shared. If it's used by only one feature, keep it inside that feature folder. This prevents premature abstraction and makes refactoring easier: change a feature-specific component without worrying about breaking other features.

Can I keep old type-based structure while introducing features?

Yes, during migration. Have both src/components/ (shared) and src/features/auth/components/ (auth-specific) coexist for a few weeks. But eventually, delete the old structure. Ambiguity (should I look in components/ or features/auth/components/?) causes bugs.

What if a component is shared across features but used differently in each?

If a component is truly different in each context, it's not shared. Keep it inside each feature. If it's the same component but initialized differently, pass props to parameterize it. Over-sharing code is a common mistake that makes refactoring harder.

Does feature-based structure work with Next.js or Remix?

Absolutely. These frameworks have their own routing (pages/ directory), so move routing to the framework, but use feature structure for business logic inside src/ or lib/. A Next.js app might have app/ (routing) and src/features/ (logic) living side by side.

How do I handle global state (Redux, Zustand) with features?

Create a store/ folder parallel to features/ and organize slices/modules by feature. Or embed a feature's state inside its folder: features/auth/store/authSlice.js. The first approach is clearer for teams with many features sharing state. The second is simpler for smaller apps.

Further Reading