
When you start a new React project, maintaining an intuitive folder structure feels effortless. A standard setup with /components, /hooks, /pages, and /utils works beautifully for a personal side project or an MVP with half a dozen routes.
However, as the application growing to 50+ pages, hundreds of components, and multiple engineering teams working in parallel, the initial setup often collapses under its own weight. Developers face to modifying five different top-level folders to make a simple change to a single feature, circular dependency hell, and continuous friction during code reviews.
Over years of building and scaling enterprise-grade React and Next.js applications, I have converged on a predictable, modular, and maintainable architecture designed to scale seamlessly with team size and feature complexity.
Here is a comprehensive breakdown of how I structure a large-scale React application.
1. The Core Philosophy: Feature-First over Layer-First
The fundamental shift in scaling a React application is transitioning from a layer-first organization to a feature-first (domain-driven) organization.
Layer-First (The Monolithic Approach)
src/
├── components/
│ ├── UserProfile.tsx
│ ├── OrderHistory.tsx
│ ├── Navbar.tsx
├── hooks/
│ ├── useUser.ts
│ ├── useOrders.ts
├── services/
│ ├── userService.ts
│ ├── orderService.ts
└── types/
├── user.ts
└── order.ts- The Problem: Related code is scattered across the entire repository. To update the user profile feature, you must jump across four different directories. When it comes time to delete, refactor, or test a feature, isolating its dependencies becomes a tedious scavenger hunt.
Feature-First (Domain-Driven Architecture)
src/
├── features/
│ ├── user-profile/
│ ├── order-management/
│ └── authentication/- The Solution: Keep code that changes together co-located. A feature folder encapsulates all domain logic, components, state management, and type definitions required for that specific business domain.
2. Directory Structure Blueprint
Here is the full structure of a production-ready React codebase:
src/
├── app/ # Application routes, entry points, layouts, and global providers
│ ├── routes/ # Router configuration (e.g., React Router or Next.js App Router)
│ └── provider.tsx # Global Context Providers wrapper (QueryClient, Auth, Theme)
│
├── assets/ # Static assets (fonts, global images, SVG icons)
│
├── components/ # Generic, domain-agnostic UI components (Design System / UI Kit)
│ ├── ui/ # Primitive components (Button, Modal, Input, Badge, Table)
│ └── feedback/ # Toast notifications, Loaders, Error boundaries
│
├── config/ # Application configuration constants, env validation, third-party keys
│ ├── env.ts # Type-safe environment variable parsing (e.g., Zod env validation)
│ └── constants.ts # Global application constants
│
├── features/ # Business domain modules (Domain-Driven core)
│ ├── authentication/ # Self-contained feature module
│ │ ├── api/ # Query/Mutation hooks and API request calls specific to Auth
│ │ ├── components/ # UI components specific to Auth (e.g., LoginForm, SignupCard)
│ │ ├── hooks/ # Custom hooks specific to Auth domain
│ │ ├── stores/ # Local state stores (if applicable)
│ │ ├── types/ # TypeScript interfaces and types for Auth
│ │ ├── utils/ # Domain-specific helpers (e.g., password strength validation)
│ │ └── index.ts # Public API boundary for the feature module
│ │
│ └── billing/ # Another isolated feature module
│
├── hooks/ # Global, reusable custom hooks (e.g., useDebounce, useMediaQuery)
│
├── lib/ # Pre-configured third-party instances (Axios, TanStack Query, Analytics)
│ ├── api-client.ts # Configured Axios / Fetch wrapper with interceptors
│ └── react-query.ts # React Query client defaults
│
├── stores/ # App-wide global UI state stores (e.g., Zustand UI store, Theme)
│
├── types/ # Shared global TypeScript types and generic utility types
│
└── utils/ # Global helper functions (formatting dates, currency, string manipulation)3. Key Architectural Principles & Enforcements
Rule 1: Enforce Strict Module Boundaries (The Public API Pattern)
Every folder inside src/features/* should behave like an internal npm package with a single index.ts file acting as its public gateway.
src/features/authentication/index.ts
// Explicitly expose only what other parts of the app are allowed to use
export { LoginForm } from './components/LoginForm';
export { useAuth } from './hooks/useAuth';
export type { User, AuthStatus } from './types';Why this matters:
- Encapsulation: Internal sub-components (like
PasswordStrengthMeter.tsxorAuthHeader.tsx) remain private to theauthenticationfeature. - Refactoring Freedom: You can restructure internal files within a feature without breaking imports across the rest of the application.
- Preventing Spaghetti Dependencies: Cross-feature imports must go through the public interface (
@/features/authentication).
Rule 2: Separate UI, Logic, and Data Fetching
Keep components lean by extracting data fetching and side effects into custom hooks.
BAD: Everything in one monolithic component
// ❌ Bloated component handling UI, API call, and state
export const UserProfileCard = ({ userId }: { userId: string }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return <div>{user?.name}</div>;
};GOOD: Clean separation of concerns
- Custom Hook for Data Fetching (
src/features/user-profile/api/use-user.ts):
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { User } from '../types';
export const useUser = (userId: string) => {
return useQuery<User>({
queryKey: ['user', userId],
queryFn: () => apiClient.get(`/users/${userId}`).then((res) => res.data),
});
};- UI Component (
src/features/user-profile/components/UserProfileCard.tsx):
import { Spinner } from '@/components/ui/Spinner';
import { useUser } from '../api/use-user';
export const UserProfileCard = ({ userId }: { userId: string }) => {
const { data: user, isLoading, error } = useUser(userId);
if (isLoading) return <Spinner />;
if (error || !user) return <div>Failed to load profile.</div>;
return (
<div className="p-4 border rounded-lg shadow-sm">
<h3 className="text-lg font-bold">{user.name}</h3>
<p className="text-sm text-gray-600">{user.email}</p>
</div>
);
};4. Pragmatic State Management Hierarchy
A common pitfall in large React applications is treating all state equally. A robust architecture separates state into four distinct tiers:
| State Tier | Recommended Tool | Purpose & Example |
|---|---|---|
| Server State | TanStack Query / SWR | API response caching, revalidation, optimistic updates |
| Local UI State | useState, useReducer | Modal visibility, form field toggles, dropdown open/closed |
| Global UI State | Zustand / Jotai | Sidebar open state, global user preferences, persistent active theme |
| URL State | useSearchParams / nuqs | Search queries, pagination page numbers, table filters (deep-linkable) |
Pro Tip: Keep 80–90% of your data layer in Server State using a tool like TanStack Query. Avoid duplicating API responses in global Redux/Zustand stores.
5. Type-Safe Infrastructure & Alias Imports
Configure TypeScript path aliases to eliminate messy relative imports like ../../../../components/ui/Button.
tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/app/*": ["src/app/*"],
"@/components/*": ["src/components/*"],
"@/config/*": ["src/config/*"],
"@/features/*": ["src/features/*"],
"@/hooks/*": ["src/hooks/*"],
"@/lib/*": ["src/lib/*"],
"@/stores/*": ["src/stores/*"],
"@/types/*": ["src/types/*"],
"@/utils/*": ["src/utils/*"]
}
}
}This enforces consistent import paths regardless of file depth:
// Clean, predictable, refactor-friendly
import { Button } from '@/components/ui/Button';
import { useAuth } from '@/features/authentication';
import { apiClient } from '@/lib/api-client';Summary & Checklist for Refactoring
If you are looking to scale your current React codebase or restructure a legacy monolith, use this simple checklist:
- Are components grouped by domain/feature rather than file type?
- Do feature folders expose a public
index.tsboundary? - Are shared design system components strictly domain-agnostic?
- Is server state decoupled from global client state using TanStack Query or SWR?
- Are import paths standardized using TypeScript path aliases?
A well-structured codebase is not about strict dogmatism—it is about lowering cognitive load, enabling parallel development, and making wrong implementations hard to write.