Feature-Based Folder Structure
How you organize files determines how fast your team ships. Traditional structures group by file type (components/, hooks/, utils/). Feature-based structures group by domain (auth/, dashboard/, cart/). One scales. The other doesn't.
Table of Contents
Overview
Feature-based folder structure organizes code by domain or feature — not by file type. Instead of putting all components in components/, all hooks in hooks/, and all services in services/, you group everything related to a feature together: auth/ contains its components, hooks, services, types, and tests.
This approach scales because adding a feature means adding a folder — not scattering files across a dozen directories. Deleting a feature means deleting one folder. Two teams working on different features never touch the same files.
In interviews, "How would you structure a large frontend app?" is one of the most common architecture questions. The answer reveals whether you think about code organization at scale — or just at the file level.
Why this matters
At 50 files, any structure works. At 500 files, structure determines whether your team ships features in days or weeks. Feature-based organization is the industry standard for production React, Vue, and Angular applications.
The Problem: Traditional Folder Structure
Most tutorials and starter projects organize code by file type. This feels natural at first — but it breaks down as the application grows.
src/ ├── components/ │ ├── LoginForm.tsx │ ├── SignupForm.tsx │ ├── Dashboard.tsx │ ├── DashboardStats.tsx │ ├── DashboardChart.tsx │ ├── UserProfile.tsx │ ├── UserAvatar.tsx │ ├── CartItem.tsx │ ├── CartSummary.tsx │ ├── ProductCard.tsx │ ├── ProductList.tsx │ ├── SearchBar.tsx │ ├── SearchResults.tsx │ ├── Header.tsx │ ├── Sidebar.tsx │ └── ... (200+ more components) ├── hooks/ │ ├── useAuth.ts │ ├── useDashboard.ts │ ├── useCart.ts │ ├── useSearch.ts │ └── ... (50+ more hooks) ├── services/ │ ├── authService.ts │ ├── dashboardService.ts │ ├── cartService.ts │ └── ... (30+ more services) ├── utils/ │ ├── authUtils.ts │ ├── formatters.ts │ └── validators.ts ├── types/ │ ├── auth.ts │ ├── dashboard.ts │ ├── cart.ts │ └── ... (20+ more type files) └── styles/ ├── login.css ├── dashboard.css └── ... (40+ more stylesheets)
Scattered Feature Code
To understand the 'auth' feature, you open: components/LoginForm.tsx, hooks/useAuth.ts, services/authService.ts, types/auth.ts, styles/login.css — 5 files in 5 directories.
Merge Conflicts
Two developers adding unrelated features both edit components/index.ts or hooks/index.ts. The barrel file becomes a constant source of merge conflicts.
Unclear Ownership
Who owns components/? Everyone. When everyone owns a directory, nobody maintains it. Files accumulate, naming conventions drift, dead code lingers.
Difficult Deletion
Removing a feature means hunting through every directory for related files. Miss one and you have dead code. Delete the wrong one and you break another feature.
Task: "Fix a bug in the search feature" With type-based structure, a developer must: 1. Open components/ → find SearchBar.tsx, SearchResults.tsx 2. Open hooks/ → find useSearch.ts 3. Open services/ → find searchService.ts 4. Open types/ → find search.ts 5. Open utils/ → find searchUtils.ts (if it exists) 6. Open styles/ → find search.css (if it exists) 7. Figure out which other components import from search → 6+ directories, constant context-switching Task: "Delete the search feature" → Hunt through every directory, hope you found everything, pray nothing else depends on searchUtils.ts
The root cause
Type-based structure optimizes for what a file is (component, hook, service). Feature-based structure optimizes for what a file does(auth, search, cart). In a large app, you almost always think in features, not file types.
What is Feature-Based Structure?
Feature-based structure groups all files related to a domain or feature into a single directory. Each feature folder is a self-contained module with its own components, hooks, services, types, and tests.
src/ ├── features/ │ ├── auth/ │ │ ├── components/ │ │ │ ├── LoginForm.tsx │ │ │ ├── SignupForm.tsx │ │ │ └── AuthGuard.tsx │ │ ├── hooks/ │ │ │ └── useAuth.ts │ │ ├── services/ │ │ │ └── authService.ts │ │ ├── types/ │ │ │ └── auth.types.ts │ │ └── index.ts ← public API (barrel export) │ │ │ ├── dashboard/ │ │ ├── components/ │ │ │ ├── Dashboard.tsx │ │ │ ├── StatsCard.tsx │ │ │ └── Chart.tsx │ │ ├── hooks/ │ │ │ └── useDashboardData.ts │ │ ├── services/ │ │ │ └── dashboardService.ts │ │ └── index.ts │ │ │ └── cart/ │ ├── components/ │ │ ├── CartItem.tsx │ │ └── CartSummary.tsx │ ├── hooks/ │ │ └── useCart.ts │ ├── services/ │ │ └── cartService.ts │ └── index.ts │ ├── shared/ ← truly shared code │ ├── components/ │ │ ├── Button.tsx │ │ ├── Input.tsx │ │ └── Modal.tsx │ ├── hooks/ │ │ └── useDebounce.ts │ └── utils/ │ └── formatDate.ts │ ├── app/ ← routing / pages │ ├── layout.tsx │ └── page.tsx └── styles/ └── globals.css
Each feature is a self-contained module
Everything the auth feature needs — components, hooks, services, types, tests — lives inside features/auth/. No need to look anywhere else.
Features expose a public API via index.ts
Other features import from the barrel file: import { LoginForm, useAuth } from '@/features/auth'. Internal implementation details are hidden.
Shared code lives in a separate directory
Truly reusable code (Button, Input, useDebounce, formatDate) lives in shared/. If it's used by 2+ features, it belongs in shared. If it's used by one feature, it stays in that feature.
Features don't import each other's internals
features/cart/ should never import features/auth/components/LoginForm.tsx directly. It imports from the public API: features/auth/index.ts. This enforces boundaries.
The mental model
Think of each feature folder as a mini-library. It has a public API (index.ts), internal implementation (components, hooks, services), and clear boundaries. Other features are consumers of the API, not collaborators on the internals.
Example Folder Structures
The contrast between type-based and feature-based becomes stark when you see them side by side for the same application.
src/ ├── components/ ← 150+ files, all mixed together │ ├── LoginForm.tsx │ ├── ProductCard.tsx │ ├── CartItem.tsx │ ├── CheckoutForm.tsx │ ├── OrderSummary.tsx │ ├── SearchBar.tsx │ ├── UserProfile.tsx │ ├── ReviewCard.tsx │ └── ... (142 more) ├── hooks/ ← 40+ hooks, no grouping │ ├── useAuth.ts │ ├── useProducts.ts │ ├── useCart.ts │ ├── useCheckout.ts │ ├── useSearch.ts │ └── ... (35 more) ├── services/ ← 20+ services │ ├── authService.ts │ ├── productService.ts │ ├── cartService.ts │ └── ... (17 more) ├── types/ ← 25+ type files └── utils/ ← 15+ utility files Problems: • "Where is the checkout code?" → scattered across 5 directories • "Who owns components/?" → everyone (nobody) • "Can I safely delete ReviewCard?" → check every import in 150 files • Adding a feature = editing 5+ directories
src/ ├── features/ │ ├── auth/ │ │ ├── LoginForm.tsx │ │ ├── SignupForm.tsx │ │ ├── useAuth.ts │ │ ├── authService.ts │ │ ├── auth.types.ts │ │ └── index.ts │ │ │ ├── products/ │ │ ├── ProductCard.tsx │ │ ├── ProductList.tsx │ │ ├── ProductDetail.tsx │ │ ├── useProducts.ts │ │ ├── productService.ts │ │ ├── product.types.ts │ │ └── index.ts │ │ │ ├── cart/ │ │ ├── CartItem.tsx │ │ ├── CartSummary.tsx │ │ ├── useCart.ts │ │ ├── cartService.ts │ │ ├── cart.types.ts │ │ └── index.ts │ │ │ ├── checkout/ │ │ ├── CheckoutForm.tsx │ │ ├── PaymentStep.tsx │ │ ├── OrderSummary.tsx │ │ ├── useCheckout.ts │ │ ├── checkoutService.ts │ │ └── index.ts │ │ │ ├── search/ │ │ ├── SearchBar.tsx │ │ ├── SearchResults.tsx │ │ ├── useSearch.ts │ │ ├── searchService.ts │ │ └── index.ts │ │ │ └── reviews/ │ ├── ReviewCard.tsx │ ├── ReviewForm.tsx │ ├── useReviews.ts │ └── index.ts │ ├── shared/ │ ├── ui/ ← reusable primitives │ │ ├── Button.tsx │ │ ├── Input.tsx │ │ ├── Modal.tsx │ │ └── Spinner.tsx │ ├── hooks/ │ │ ├── useDebounce.ts │ │ └── useLocalStorage.ts │ └── utils/ │ ├── formatCurrency.ts │ └── formatDate.ts │ └── app/ ← routing layer ├── layout.tsx ├── page.tsx └── (routes)/ Benefits: • "Where is the checkout code?" → features/checkout/ • "Who owns cart?" → the cart team • "Can I delete reviews?" → delete features/reviews/, done • Adding a feature = adding one folder
| Dimension | Type-Based | Feature-Based |
|---|---|---|
| Find feature code | Search across 5+ directories | Open one feature folder |
| Add a feature | Create files in 5+ directories | Create one new folder |
| Delete a feature | Hunt through every directory | Delete one folder |
| Merge conflicts | Frequent (shared barrel files) | Rare (isolated feature folders) |
| Team ownership | Unclear (everyone edits components/) | Clear (cart team owns features/cart/) |
| Onboarding | Must learn entire codebase structure | Learn one feature folder at a time |
The litmus test
Ask yourself: "If I need to work on the cart feature, how many directories do I open?" If the answer is more than one, your structure is optimized for file types, not for developer productivity.
Benefits of Feature-Based Structure
The benefits compound as the application and team grow. What feels like a minor organizational choice at 10 files becomes a critical productivity multiplier at 500.
Scalability
Adding a feature means adding a folder. The existing structure doesn't change. 10 features or 100 features — the pattern is the same. No directory grows unbounded.
Discoverability
New developers find code by feature name, not by guessing which directory a file is in. 'Where is the auth code?' → features/auth/. No searching required.
Team Independence
The cart team works in features/cart/. The auth team works in features/auth/. They never touch the same files. Merge conflicts drop dramatically.
Safe Deletion
Removing a feature is deleting one folder and removing its imports from the routing layer. No orphaned files in hooks/ or services/ that nobody remembers to clean up.
// features/auth/index.ts — the public API // Only export what other features need export { LoginForm } from "./LoginForm"; export { SignupForm } from "./SignupForm"; export { AuthGuard } from "./AuthGuard"; export { useAuth } from "./useAuth"; export type { User, AuthState } from "./auth.types"; // Internal implementation details are NOT exported: // - authService.ts (used only by useAuth internally) // - authUtils.ts (helper functions, not needed externally) // - AuthContext.tsx (internal context provider) // Other features import from the barrel: // import { useAuth, LoginForm } from "@/features/auth"; // They never reach into internal files.
Encapsulation at the folder level
The barrel export (index.ts) is the feature's public API. It defines what other features can use and hides implementation details. This is the same encapsulation principle as classes or modules — applied to folders.
How to Design Features
The hardest part of feature-based structure is deciding what constitutes a "feature." Too granular and you have 50 tiny folders. Too broad and you have 3 massive folders. Here's how to find the right boundaries.
A feature is a cohesive domain area that: ✅ Has its own UI (one or more pages/components) ✅ Has its own data/state (API calls, local state) ✅ Can be described in 1-2 words (auth, cart, search) ✅ Could theoretically be developed by one team ✅ Has clear boundaries (you know what's "in" and "out") Good features: Bad features: ├── auth/ ├── buttons/ (too granular) ├── dashboard/ ├── api/ (technical layer) ├── cart/ ├── data/ (too vague) ├── checkout/ ├── misc/ (catch-all) ├── search/ ├── helpers/ (technical layer) ├── user-profile/ └── new-stuff/ (meaningless) ├── notifications/ ├── settings/ └── analytics/
Start with user-facing domains
Think about what the user sees: authentication, product browsing, shopping cart, checkout, profile settings. Each of these is a natural feature boundary.
Check the independence test
Can this feature be developed, tested, and deployed without changing other features? If yes, it's a good boundary. If it's deeply entangled with another feature, they might be one feature.
Keep features at 5-20 files
If a feature has 3 files, it might be too granular — consider merging with a related feature. If it has 40+ files, it's too broad — split into sub-features.
Name features after the domain, not the UI
Use 'auth' not 'login-page'. Use 'cart' not 'cart-sidebar'. The feature encompasses the entire domain — components, logic, services — not just one UI element.
E-commerce App: features/ ├── auth/ → login, signup, password reset, session management ├── products/ → product listing, detail, filtering, sorting ├── cart/ → add to cart, update quantity, remove, cart summary ├── checkout/ → shipping, payment, order confirmation ├── orders/ → order history, order detail, tracking ├── reviews/ → write review, view reviews, ratings ├── search/ → search bar, results, filters, suggestions ├── user-profile/ → profile view, edit profile, avatar └── notifications/ → notification list, preferences, real-time updates SaaS Dashboard: features/ ├── auth/ → login, SSO, team invites ├── dashboard/ → overview stats, charts, recent activity ├── projects/ → project list, create, settings ├── editor/ → document editor, toolbar, collaboration ├── billing/ → plans, payment methods, invoices ├── settings/ → account settings, team management └── analytics/ → usage charts, export, date range picker
When in doubt, start broad
It's easier to split a large feature into two smaller ones than to merge two small features. Start with broader boundaries and split when a feature folder grows beyond 20 files or when two sub-teams need to work independently.
Shared vs Feature-Specific Code
The most common question: "Where does this file go — in the feature or in shared?" The rule is simple: if it's used by one feature, it belongs in that feature. If it's used by two or more, it belongs in shared.
Is this code used by MORE THAN ONE feature? │ ├─ NO → Put it in the feature folder │ features/cart/CartItem.tsx │ features/cart/useCart.ts │ features/cart/cartService.ts │ └─ YES → Put it in shared/ shared/ui/Button.tsx (used by every feature) shared/hooks/useDebounce.ts (used by search + autocomplete) shared/utils/formatDate.ts (used everywhere) Important: Start in the feature folder. Move to shared ONLY when a second feature needs it. Don't pre-optimize. ❌ "This hook MIGHT be useful elsewhere" → put in shared/ ✅ "This hook IS used by auth and cart" → move to shared/
| Goes in Feature Folder | Goes in Shared |
|---|---|
| CartItem.tsx (only used in cart) | Button.tsx (used by every feature) |
| useCart.ts (cart-specific state) | useDebounce.ts (generic utility hook) |
| cartService.ts (cart API calls) | apiClient.ts (base HTTP client) |
| cart.types.ts (cart data types) | common.types.ts (shared types like ID, Timestamp) |
| CartSummary.test.tsx (cart tests) | test-utils.ts (shared test helpers) |
| formatCartTotal.ts (cart-specific) | formatCurrency.ts (used by cart + checkout + orders) |
shared/ ├── ui/ ← design system primitives │ ├── Button.tsx │ ├── Input.tsx │ ├── Modal.tsx │ ├── Spinner.tsx │ ├── Badge.tsx │ └── index.ts │ ├── hooks/ ← generic utility hooks │ ├── useDebounce.ts │ ├── useLocalStorage.ts │ ├── useMediaQuery.ts │ └── index.ts │ ├── utils/ ← pure utility functions │ ├── formatDate.ts │ ├── formatCurrency.ts │ ├── cn.ts (className merger) │ └── index.ts │ ├── services/ ← shared infrastructure │ ├── apiClient.ts (base fetch/axios wrapper) │ └── analytics.ts (tracking service) │ ├── types/ ← shared type definitions │ └── common.types.ts │ └── constants/ └── routes.ts Rules for shared/: • No business logic — only generic utilities • No feature-specific code • Should be stable — changes here affect every feature • Could theoretically be extracted into an npm package
The gravity rule
Code naturally "gravitates" toward shared/ over time. A hook starts in features/search/, then features/autocomplete/ needs it too, so you move it to shared/hooks/. This is healthy. What's unhealthy is starting everything in shared/ "just in case" — that recreates the type-based structure.
Real-World Example
Let's walk through structuring a real dashboard application with authentication, analytics, project management, and team settings.
src/ ├── app/ ← Next.js routing layer │ ├── layout.tsx │ ├── page.tsx → redirects to /dashboard │ ├── login/ │ │ └── page.tsx → renders <LoginForm /> │ ├── dashboard/ │ │ └── page.tsx → renders <Dashboard /> │ ├── projects/ │ │ ├── page.tsx → renders <ProjectList /> │ │ └── [id]/ │ │ └── page.tsx → renders <ProjectDetail /> │ └── settings/ │ └── page.tsx → renders <Settings /> │ ├── features/ │ ├── auth/ │ │ ├── LoginForm.tsx │ │ ├── useAuth.ts │ │ ├── AuthProvider.tsx │ │ ├── authService.ts │ │ ├── auth.types.ts │ │ └── index.ts │ │ │ ├── dashboard/ │ │ ├── Dashboard.tsx ← composes child components │ │ ├── StatsGrid.tsx │ │ ├── ActivityFeed.tsx │ │ ├── QuickActions.tsx │ │ ├── useDashboardData.ts │ │ ├── dashboardService.ts │ │ └── index.ts │ │ │ ├── projects/ │ │ ├── ProjectList.tsx │ │ ├── ProjectCard.tsx │ │ ├── ProjectDetail.tsx │ │ ├── CreateProjectModal.tsx │ │ ├── useProjects.ts │ │ ├── projectService.ts │ │ ├── project.types.ts │ │ └── index.ts │ │ │ ├── analytics/ │ │ ├── AnalyticsChart.tsx │ │ ├── DateRangePicker.tsx │ │ ├── MetricCard.tsx │ │ ├── useAnalytics.ts │ │ ├── analyticsService.ts │ │ └── index.ts │ │ │ └── settings/ │ ├── Settings.tsx │ ├── ProfileSection.tsx │ ├── TeamSection.tsx │ ├── BillingSection.tsx │ ├── useSettings.ts │ ├── settingsService.ts │ └── index.ts │ ├── shared/ │ ├── ui/ │ │ ├── Button.tsx │ │ ├── Card.tsx │ │ ├── Modal.tsx │ │ ├── Table.tsx │ │ └── index.ts │ ├── hooks/ │ │ ├── useDebounce.ts │ │ └── useMediaQuery.ts │ ├── utils/ │ │ ├── formatDate.ts │ │ └── cn.ts │ └── services/ │ └── apiClient.ts │ └── styles/ └── globals.css
// app/dashboard/page.tsx — thin routing layer // The page just imports and renders the feature component import { Dashboard } from "@/features/dashboard"; import { AuthGuard } from "@/features/auth"; export default function DashboardPage() { return ( <AuthGuard> <Dashboard /> </AuthGuard> ); } // features/dashboard/Dashboard.tsx — the actual feature import { StatsGrid } from "./StatsGrid"; import { ActivityFeed } from "./ActivityFeed"; import { QuickActions } from "./QuickActions"; import { useDashboardData } from "./useDashboardData"; export function Dashboard() { const { stats, activity, isLoading } = useDashboardData(); if (isLoading) return <Spinner />; return ( <div> <StatsGrid stats={stats} /> <div className="grid grid-cols-2 gap-6"> <ActivityFeed items={activity} /> <QuickActions /> </div> </div> ); } // Key insight: the page (routing layer) is thin. // The feature (Dashboard) owns all the logic and UI. // Pages compose features. Features compose components.
Pages are thin, features are thick
In feature-based structure, route pages are just glue — they import feature components and compose them. All the real logic, state, and UI lives inside the feature folders. This keeps the routing layer clean and the features portable.
Scaling Strategy
Feature-based structure isn't a one-time decision — it evolves with the application. Here's how to scale it from a small project to a large one.
── Stage 1: Small App (5-15 files) ──────────────── src/ ├── components/ │ ├── LoginForm.tsx │ ├── Dashboard.tsx │ └── Header.tsx ├── hooks/ │ └── useAuth.ts └── app/ └── page.tsx At this size, type-based is fine. Don't over-engineer. ── Stage 2: Growing App (30-80 files) ───────────── src/ ├── features/ │ ├── auth/ │ │ ├── LoginForm.tsx │ │ ├── useAuth.ts │ │ └── index.ts │ └── dashboard/ │ ├── Dashboard.tsx │ ├── StatsCard.tsx │ └── index.ts ├── shared/ │ └── ui/ │ └── Button.tsx └── app/ Features emerge naturally. Migrate incrementally. ── Stage 3: Large App (200+ files) ──────────────── src/ ├── features/ │ ├── auth/ │ │ ├── components/ │ │ │ ├── LoginForm.tsx │ │ │ └── SignupForm.tsx │ │ ├── hooks/ │ │ │ └── useAuth.ts │ │ ├── services/ │ │ │ └── authService.ts │ │ ├── __tests__/ │ │ │ └── useAuth.test.ts │ │ └── index.ts │ └── ... (10+ features) ├── shared/ └── app/ Features get sub-directories when they grow beyond 8-10 files. ── Stage 4: Monorepo (500+ files, multiple teams) ─ packages/ ├── features/ │ ├── auth/ → separate package │ ├── dashboard/ → separate package │ └── cart/ → separate package ├── shared-ui/ → design system package └── app/ → shell that composes features Each feature becomes an independent package with its own package.json, tests, and build. Teams deploy independently.
Start flat, add depth when needed
Begin with files directly in the feature folder (auth/LoginForm.tsx). Only add sub-directories (auth/components/LoginForm.tsx) when the feature grows beyond 8-10 files.
Split features when they get too large
If features/settings/ has 30+ files covering profile, billing, and team management, split into features/profile/, features/billing/, features/team/.
Avoid deep nesting
Maximum 3 levels deep: features/auth/components/LoginForm.tsx. If you're deeper than that, your feature boundaries are too broad or your components are too granular.
The migration path
You don't need to restructure everything at once. Migrate one feature at a time: create features/auth/, move auth-related files into it, update imports. The rest of the codebase stays untouched. Over weeks, the entire app migrates incrementally.
Common Mistakes
Feature-based structure is simple in concept but easy to get wrong in practice. These are the most common pitfalls.
Mixing type-based and feature-based
Having both features/auth/ AND a top-level components/ with auth-related components. Developers don't know which to use, and code ends up in both places.
✅Pick one approach and commit. If you're going feature-based, move ALL feature-specific code into feature folders. Only truly shared primitives (Button, Input) stay in shared/.
Over-engineering from day one
Creating features/auth/components/forms/ inputs/EmailInput.tsx for a 10-file app. Deep nesting and sub-directories add overhead when the app is small.
✅Start flat. Put files directly in the feature folder. Add sub-directories only when a feature grows beyond 8-10 files. Let the structure evolve with the app.
Too many tiny features
Creating features/login/, features/signup/, features/password-reset/, features/session/ as separate features. These are all part of 'auth' and should live together.
✅Group related functionality into cohesive features. If two 'features' always change together and share types/services, they're one feature.
Poor naming conventions
Inconsistent naming: features/auth/ uses camelCase files, features/Dashboard/ uses PascalCase folders, features/user_profile/ uses snake_case. No convention for index.ts exports.
✅Establish conventions early: kebab-case for folders (user-profile/), PascalCase for components (UserProfile.tsx), camelCase for hooks/services (useAuth.ts). Document in a CONTRIBUTING.md.
Features importing each other's internals
features/cart/CartItem.tsx imports features/products/productService.ts directly, bypassing the barrel export. This creates hidden coupling between features.
✅Features only import from each other's index.ts barrel exports. Use ESLint rules (eslint-plugin-boundaries) to enforce this. If a feature needs something from another, import from the public API.
Putting everything in shared/
shared/ becomes a dumping ground with 100+ files because developers default to 'it might be reused.' This recreates the type-based structure inside shared/.
✅Apply the rule strictly: code starts in a feature folder. It moves to shared/ ONLY when a second feature actually needs it. Shared should be small and stable.
Interview Questions
These questions test whether you can think about code organization at scale — a key skill for senior frontend roles.
Q:What is a feature-based folder structure?
A: It's an approach to organizing code by domain/feature rather than by file type. Instead of grouping all components in components/, all hooks in hooks/, etc., you group everything related to a feature together: features/auth/ contains its components, hooks, services, types, and tests. Each feature is a self-contained module with a public API (barrel export). This scales better because adding/removing features is isolated to one directory.
Q:Why is feature-based structure better than type-based for large apps?
A: Three main reasons: (1) Discoverability — all code for a feature is in one place, not scattered across 5+ directories. (2) Team scaling — teams own feature folders, not shared directories, reducing merge conflicts. (3) Modularity — features are self-contained with clear boundaries, making it safe to add, modify, or delete features without affecting others. Type-based structure works for small apps but creates navigation overhead and coupling at scale.
Q:How do you decide what goes in shared/ vs a feature folder?
A: Simple rule: if code is used by only one feature, it stays in that feature folder. If it's used by two or more features, it moves to shared/. Start in the feature folder — don't pre-optimize by putting things in shared/ 'just in case.' Shared/ should contain only generic utilities (Button, useDebounce, formatDate), not business logic. Think of shared/ as an internal library that could theoretically be an npm package.
Q:How do you handle cross-feature communication?
A: Features communicate through their public APIs (barrel exports). Feature A imports from feature B's index.ts, never from internal files. For shared state, use a state management library (Zustand, Redux) or React Context at the app level. For events, use a pub/sub pattern or shared hooks. The key principle: features depend on each other's interfaces, not implementations.
Q:How would you migrate a type-based codebase to feature-based?
A: Incrementally, one feature at a time. (1) Pick the most isolated feature (e.g., auth). (2) Create features/auth/. (3) Move all auth-related files into it. (4) Update imports across the codebase. (5) Add a barrel export (index.ts). (6) Repeat for the next feature. This can be done over weeks without disrupting the team. Use ESLint import rules to prevent new code from using the old structure.
Q:What defines a good feature boundary?
A: A good feature: (1) has its own UI and data (components + API calls), (2) can be described in 1-2 words (auth, cart, search), (3) could be developed by one team independently, (4) has 5-20 files (not too granular, not too broad), (5) changes together — when you modify the feature, most changes are within its folder. If two 'features' always change together, they're probably one feature. If one feature has 40+ files, it should be split.
Q:How do you prevent features from becoming tightly coupled?
A: Three strategies: (1) Barrel exports — features only import from each other's index.ts, never internal files. (2) ESLint boundaries — use eslint-plugin-boundaries to enforce import rules at the linter level. (3) Dependency direction — establish a clear hierarchy (shared → features → app). Features can depend on shared/ but shared/ never depends on features/. Features should minimize direct dependencies on other features.
Practice Section
These scenarios test your ability to apply feature-based thinking to real architecture decisions.
You join a team with a React app that has 200+ components in a flat components/ directory, 60 hooks in hooks/, and 30 services in services/. Developers spend 20% of their time just finding files. Two teams frequently have merge conflicts in shared barrel files.
How would you restructure this codebase?
Answer: Migrate incrementally to feature-based structure. Start by identifying 8-12 natural features (auth, dashboard, search, etc.). Pick the most isolated feature first (e.g., settings). Create features/settings/, move all settings-related components, hooks, and services into it, update imports, add a barrel export. Do one feature per sprint. Set up ESLint rules to prevent new files from going into the old flat directories. Within 2-3 months, the entire codebase is migrated without a risky big-bang rewrite.
A developer creates a DatePicker component inside features/analytics/ because that's where they needed it first. Two months later, features/reports/ and features/scheduling/ also need a date picker. They import it directly from features/analytics/components/DatePicker.tsx.
What's wrong and how do you fix it?
Answer: Two problems: (1) DatePicker is now shared code living in a feature folder — it should move to shared/ui/DatePicker.tsx. (2) Other features are importing internal files instead of using the barrel export. Fix: move DatePicker to shared/ui/, update all imports to use shared/ui. Add an ESLint rule (eslint-plugin-boundaries) that prevents features from importing other features' internal files. This enforces the rule that cross-feature imports go through barrel exports only.
Your e-commerce app has a features/products/ folder with 45 files: product listing, product detail, product reviews, product comparison, product recommendations, and product admin. The folder is becoming hard to navigate.
How would you split this feature?
Answer: Split by sub-domain: features/product-catalog/ (listing, filtering, sorting — 10 files), features/product-detail/ (detail page, images, specs — 8 files), features/reviews/ (review list, write review, ratings — 8 files), features/product-comparison/ (comparison table, add to compare — 6 files), features/recommendations/ (recommendation engine, carousel — 5 files). Shared product types move to shared/types/product.types.ts. Each sub-feature gets its own barrel export and clear boundaries.
A junior developer asks: 'I'm building a useLocalStorage hook for the settings feature. Should I put it in features/settings/hooks/ or shared/hooks/? It's only used by settings right now, but it seems generic enough to be shared.'
What advice would you give?
Answer: Put it in features/settings/ for now. The rule is: code starts in the feature where it's first needed. If another feature needs useLocalStorage later, move it to shared/hooks/ at that point. Pre-emptively putting 'generic-looking' code in shared/ leads to a bloated shared directory full of code that's only used once. Let actual usage drive the decision, not speculation about future reuse.
Your team is starting a new project from scratch. The tech lead wants to set up the perfect feature-based structure on day one with sub-directories for components, hooks, services, types, and tests inside each feature — even though the app currently has 3 pages.
Is this the right approach?
Answer: No — this is over-engineering. For a 3-page app, start with a simple flat structure: features/auth/LoginForm.tsx, features/auth/useAuth.ts (no sub-directories). Add sub-directories only when a feature grows beyond 8-10 files. The structure should evolve with the app. Premature structure adds navigation overhead and makes simple changes feel heavy. Document the convention so the team knows when to add depth, but start minimal.
Cheat Sheet
Quick-reference rules for feature-based folder structure.
Quick Revision Cheat Sheet
Core principle: Organize by feature/domain, not by file type
Feature folder contains: Components, hooks, services, types, tests — everything for that feature
Barrel export (index.ts): Public API of the feature — other features import from here only
Shared/ directory: Generic utilities used by 2+ features (Button, useDebounce, formatDate)
Where to start new code: In the feature folder. Move to shared/ only when a second feature needs it
Good feature size: 5-20 files. Smaller = too granular. Larger = split into sub-features
Feature naming: Name after the domain (auth, cart, search), not the UI (login-page, sidebar)
Cross-feature imports: Always through barrel exports, never internal files
Nesting depth: Maximum 3 levels: features/auth/components/LoginForm.tsx
When to add sub-directories: When a feature grows beyond 8-10 files
When to split a feature: When it exceeds 20+ files or two sub-teams need independence
Migration strategy: Incremental — one feature at a time, not a big-bang rewrite
Enforce boundaries: Use eslint-plugin-boundaries to prevent cross-feature internal imports
Pages/routes are thin: Route pages just import and compose feature components