Folder Structure Best Practices
There is no single correct way to structure a React project. But there are patterns that scale well and patterns that create friction as teams and codebases grow. This page documents the approaches used in successful production applications and the reasoning behind each choice.
Layer-Based vs Feature-Based Structure
The two dominant approaches are organizing files by technical layer (components, hooks, services) or by feature (auth, dashboard, checkout).
Layer-Based | Feature-Based | |
|---|---|---|
Organization | By type: | By domain: |
Works well at | Small projects, ≤3 developers | Medium to large projects, growing teams |
Problem at scale | Changing one feature touches 5+ folders | N/A — related files stay together |
Onboarding | Familiar to newcomers | Requires understanding the domain |
Co-deletion | Hard — parts of a feature are spread out | Easy — delete one folder to remove a feature |
Feature-based structure wins at scale because it mirrors how developers think about work: "I am working on authentication" maps to src/features/auth/, not to simultaneously touching /components/LoginForm, /hooks/useAuth, /services/authService, and /store/authSlice.
Recommended Structure for a Medium-Large App
src/
├── app/ # Next.js App Router or React Router entry
│ ├── layout.tsx
│ ├── page.tsx
│ └── (routes)/
│
├── features/ # Domain-organized feature modules
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ ├── RegisterForm.tsx
│ │ │ └── index.ts
│ │ ├── hooks/
│ │ │ ├── useAuth.ts
│ │ │ └── useSession.ts
│ │ ├── services/
│ │ │ └── authApi.ts
│ │ ├── store/
│ │ │ └── authSlice.ts
│ │ ├── types/
│ │ │ └── auth.types.ts
│ │ └── index.ts # public API of the feature
│ │
│ ├── dashboard/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── index.ts
│ │
│ └── checkout/
│ ├── components/
│ ├── hooks/
│ └── index.ts
│
├── shared/ # Truly reusable, cross-feature code
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.stories.tsx
│ │ │ └── index.ts
│ │ ├── Modal/
│ │ └── index.ts
│ ├── hooks/
│ │ ├── useLocalStorage.ts
│ │ ├── useDebounce.ts
│ │ └── index.ts
│ └── utils/
│ ├── formatDate.ts
│ ├── cn.ts # className helper
│ └── index.ts
│
├── lib/ # Third-party wrappers and configuration
│ ├── axios.ts # configured axios instance
│ ├── queryClient.ts # React Query / SWR config
│ └── analytics.ts # analytics SDK wrapper
│
└── types/ # App-wide TypeScript types
├── api.types.ts
├── models.ts
└── env.d.tsFeature Module: The Index File (Public API)
Each feature directory should have an index.ts that exports only what other features are allowed to import. This creates a clear boundary — other features import from features/auth, not from internal paths:
// src/features/auth/index.ts — the public API of the auth feature
// ✓ Exported — other features may use these
export { LoginForm } from './components/LoginForm'
export { RegisterForm } from './components/RegisterForm'
export { useAuth } from './hooks/useAuth'
export type { User, AuthState } from './types/auth.types'
// ✗ Not exported — internal implementation details
// authReducer, authApi internals, useSessionRefresh (internal hook)With this pattern, the import in another feature is clean and does not expose internal structure:
// ✓ Import from the feature's public API
import { useAuth } from '@/features/auth'
// ✗ Never import from internal paths of another feature
import { useAuth } from '@/features/auth/hooks/useAuth'The shared/ Directory
Code in shared/ must have no knowledge of any specific feature. If a component or hook depends on authSlice or checkoutService, it does not belong in shared/. Move it to the feature it belongs to, or create a new feature.
shared/components/— Button, Input, Modal, Tooltip, Spinner, Badgeshared/hooks/— useDebounce, useLocalStorage, useIntersectionObserver, useMediaQueryshared/utils/— date formatting, string helpers, className utils, validationshared/icons/— SVG icon components (or re-exports from an icon library)
Co-locating Tests and Stories
Keep tests and Storybook stories next to the component file, not in a separate __tests__ folder. This makes them impossible to orphan when you move a component:
// ✓ Co-located — test and story live with the component shared/components/Button/ Button.tsx Button.test.tsx # unit / integration test Button.stories.tsx # Storybook story index.ts // ✗ Separate __tests__ folder — tests become orphaned when components move __tests__/ Button.test.tsx # where's the component? which Button?
The lib/ Directory
Third-party libraries often need configuration or wrapping before use across the app. The lib/ directory holds those wrappers, making it easy to change the underlying library in one place:
// src/lib/http.ts — configured fetch/axios wrapper
import axios from 'axios'
export const http = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
})
http.interceptors.request.use(config => {
// attach auth token
return config
})
// Now every feature imports from lib, not from axios directly:
import { http } from '@/lib/http'Naming Conventions
Type | Convention | Examples |
|---|---|---|
React component | PascalCase .tsx |
|
Custom hook | camelCase, prefix use |
|
Redux/Zustand slice | camelCase + Slice |
|
Utility function | camelCase |
|
Type file | camelCase + .types |
|
Barrel export | index.ts |
|
Test file | same name + .test |
|
Story file | same name + .stories |
|
Barrel Exports and Import Paths
Barrel files (index.ts) let consumers import from a short path instead of a full internal path:
// src/shared/components/index.ts
export { Button } from './Button'
export { Modal } from './Modal'
export { Spinner } from './Spinner'
export { Input } from './Input'
// Consumer — short, readable import
import { Button, Modal, Spinner } from '@/shared/components'
// Without barrel — verbose and fragile to file moves
import { Button } from '@/shared/components/Button/Button'
import { Modal } from '@/shared/components/Modal/Modal'