TL;DR: Stop throwing everything into components/. After 6 production apps and 3 burned-out refactors, I landed on a feature-based, domain-driven structure that scales from 1 to 100 components.
I have tried every structure: grouped by type, grouped by route, atomic design, and even "just throw it in src." This is the one that finally made me stop crying during code reviews.
The Golden Rule: Co-location of Concern
"Files that change together, live together."
If you are editing UserProfile.tsx and you have to jump to 4 different folders (components/, hooks/, types/, services/), your structure is broken.
Here is the tree view of my happy place:
text
src/
βββ features/ # The π (core domain logic)
β βββ auth/ # Single feature
β β βββ components/ # ONLY used by this feature
β β βββ hooks/ # Feature-specific hooks
β β βββ types/ # TS interfaces/domain types
β β βββ api/ # API calls for this feature
β β βββ index.ts # Public API for the feature
β βββ dashboard/
β βββ settings/
β
βββ shared/ # Reusable, but NOT business logic
β βββ ui/ # Dumb components (Button, Modal, Card)
β β βββ Button/
β β βββ Modal/
β β βββ index.ts
β βββ lib/ # Utils, formatters, constants
β βββ hooks/ # Generic hooks (useLocalStorage, useDebounce)
β βββ api/ # Axios/fetch instance, interceptors
β
βββ app/ # Next.js/React Router setup
β βββ routes/ # Route configuration
β βββ providers/ # Context providers (Theme, QueryClient)
β
βββ layouts/ # AuthLayout, DashboardLayout
β
βββ main.tsx # Entry point
Deep Dive: The features/ Folder (Where the magic happens)
Each feature is a mini-app. It owns everything it needs. No imports jumping across the galaxy.
Example: features/auth/
typescript
// features/auth/types/index.ts
export interface User {
id: string;
email: string;
role: 'admin' | 'user';
}
// features/auth/api/authApi.ts
import { apiClient } from '@/shared/api';
import type { User } from '../types';
export const authApi = {
login: (email: string, password: string) =>
apiClient.post('/auth/login', { email, password }),
logout: () => apiClient.post('/auth/logout')
};
// features/auth/hooks/useLogin.ts
import { useMutation } from '@tanstack/react-query';
import { authApi } from '../api/authApi';
export const useLogin = () => {
return useMutation({
mutationFn: authApi.login,
onSuccess: (user) => {
// Handle success locally inside the feature
}
});
};
// features/auth/components/LoginForm.tsx
import { useLogin } from '../hooks/useLogin';
import { Button } from '@/shared/ui/Button'; // Only shared UI allowed
export const LoginForm = () => {
const { mutate } = useLogin();
return mutate(...)}>Login;
};
// features/auth/index.ts (Public API)
export { LoginForm } from './components/LoginForm';
export { useLogin } from './hooks/useLogin';
export type { User } from './types';
Rule: No cross-imports between features β
typescript
// BAD: Dashboard importing directly from auth's internal folder
import { useLogin } from '@/features/auth/hooks/useLogin';
// GOOD: Use the feature's public index.ts
import { useLogin } from '@/features/auth';
Why? Later, you can delete the auth/ folder entirely without breaking dashboard/ if you do it right.
The shared/ Folder (The innocent box of Legos)
This is where pure, reusable, business-logic-free code lives.
Subfolder Contents Example
ui/ Presentational components Button, Input, Card, Modal (No API calls!)
hooks/ Generic React hooks useLocalStorage, useMediaQuery, useCopyToClipboard
lib/ Pure functions formatDate, cn() (clsx/tailwind-merge), generateId
api/ Configured HTTP client Axios instance with baseURL, interceptors, auth headers
The shared/ui/ pattern (Bonus)
I use a barrel export for UI components:
typescript
// shared/ui/Button/Button.tsx
export const Button = (props) => { ... };
// shared/ui/Button/index.ts
export * from './Button';
// shared/ui/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
Then in any feature or layout:
typescript
import { Button, Modal } from '@/shared/ui';
Why This Works (The Opinionated Part)
It destroys "God Components"
When everything lives in components/, you eventually create UserDashboardFormWithSidebarAndHeaderAndFooter.tsx. In feature folders, you are forced to split.Deleting features is safe
Marketing wants a temporary landing page feature? Clone features/landing/ into it. Then delete it. No orphaned hooks, no broken imports.Types are treated as first-class citizens
No more types.ts at the root with 3,000 lines. Each feature defines its own domain types. If two features need the same type, it belongs in shared/types/.It works with every meta-framework
Next.js (App Router): features/ sit next to app/
Vite + React Router: features/ import shared/
Remix: Same thing.
Real-World Exception (Because perfect doesn't exist)
Exception #1: Global state (Zustand, Redux, Jotai)
Store global slices in stores/ inside app/, not inside features/.
Only put feature-scoped state inside the feature folder.
Exception #2: Shared types across 3+ features
Move to shared/types/. Example: ApiResponse.
The Migration Path (If you are converting an existing app)
Create shared/ui/ and move Button, Card, Input there. (1 hour)
Pick ONE feature (e.g., auth). Create the folder structure. Move its components, hooks, and API calls inside. (2 hours)
Update imports for that one feature. Use your IDE's refactor tools.
Repeat for the second feature. The third one will take 10 minutes.
Delete the old components/ folder when it's empty. Full send.
The Verdict
Is this the only way? No.
Does it work for a 50-component side project? Overkill.
Does it work for a 500-component enterprise app with 6 developers? Yes. Every. Single. Time.
If you take one thing away: Don't organize by file type. Organize by feature.
What does your React folder structure look like? Did I miss your pet pattern? Let me know in the comments β but be nice, my structure is "opinionated but works" for a reason. π
P.S. If you want the TypeScript paths config to make @/ work:
json
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/": ["./src/"]
}
}
}
Top comments (0)