How to Structure Large React Projects
Feature folders, shared modules, and colocation strategies that keep React codebases maintainable as teams and requirements grow — patterns from real client projects.
Every large React project starts the same way: a clean src/ folder, a handful of components, and confident comments about “keeping it simple.” Six months later, there are 400 files, three developers stepping on each other’s PRs, and a utils/helpers.ts file with 2,000 lines that everyone is afraid to touch.
I’ve been called in to restructure React codebases for clients at this exact inflection point — when feature velocity has slowed not because the team is bad, but because the architecture never scaled past the prototype phase. The restructuring always follows similar principles, even though the specific folder layout varies by project type.
This article is the structure I implement on large React projects today — feature-based organization, clear boundaries, colocation, and conventions that let teams move fast without creating a monolith of interconnected components.
Why Structure Matters Before Scale
Project structure is not bureaucracy. It’s the set of decisions that answer: “Where does this file go?” and “Who is allowed to import what?” Without clear answers, developers make inconsistent choices, coupling spreads, and onboarding new team members takes weeks instead of days.
The cost of bad structure is invisible until it’s catastrophic:
- A bug fix in checkout breaks the admin dashboard because they share a poorly named utility
- Three different date formatting functions exist because nobody knew one already existed
- A “quick” feature takes two weeks because the developer couldn’t find existing patterns
Good structure makes the right thing easy and the wrong thing obvious. When a developer creates a new modal, the folder convention tells them exactly where it goes and what it can depend on.
Feature-Based vs. Type-Based Organization
The two dominant approaches:
Type-based (layered):
src/
components/
hooks/
utils/
services/
pages/
Feature-based (domain-driven):
src/
features/
auth/
checkout/
products/
shared/
ui/
lib/
Type-based works for small projects. At scale, it fails because related code scatters across folders. The checkout form component lives in components/, its hook in hooks/, its API calls in services/, and its types in types/. Changing checkout requires navigating four directories.
Feature-based colocates everything a domain needs:
src/features/checkout/
components/
CartSummary.tsx
ShippingForm.tsx
PaymentStep.tsx
hooks/
useCart.ts
useCheckout.ts
api/
checkout.api.ts
types/
checkout.types.ts
utils/
calculateTotals.ts
index.ts # public API
I use feature-based as the default on every project with more than ten routes or more than two developers.
The Folder Structure I Use in Production
Here’s the complete structure for a typical SaaS or e-commerce React app:
src/
├── app/ # Route definitions, layouts, providers
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── signup/page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ └── products/page.tsx
│ ├── layout.tsx
│ └── providers.tsx
├── features/
│ ├── auth/
│ ├── products/
│ ├── checkout/
│ └── orders/
├── shared/
│ ├── ui/ # Design system primitives
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── index.ts
│ ├── lib/ # Pure utilities, no feature knowledge
│ │ ├── format.ts
│ │ ├── cn.ts
│ │ └── api-client.ts
│ ├── hooks/ # Generic hooks (useMediaQuery, useDebounce)
│ └── types/ # Global types (User, ApiResponse)
├── config/
│ ├── env.ts
│ └── constants.ts
└── test/
├── setup.ts
└── utils/
Key rules:
features/contains domain logic — auth, products, checkoutshared/contains code with zero domain knowledgeapp/contains routing and composition only — thin files that import from features- Features never import from other features directly — they communicate through shared abstractions or the app layer
Feature Module Boundaries and Public APIs
Each feature exposes a public API through its index.ts:
// features/checkout/index.ts
export { CartDrawer } from "./components/CartDrawer";
export { useCart } from "./hooks/useCart";
export type { CartItem, CartState } from "./types/checkout.types";
Internal implementation details stay private:
features/checkout/
components/
CartDrawer.tsx # exported
CartLineItem.tsx # internal — not in index.ts
hooks/
useCart.ts # exported
useCartPersistence.ts # internal
Enforce this with ESLint import restrictions:
// eslint.config.js
{
rules: {
"no-restricted-imports": ["error", {
patterns: [
{
group: ["**/features/*/!(index)"],
message: "Import from feature public API (index.ts) only.",
},
{
group: ["**/features/checkout/**"],
importNames: ["*"],
message: "Do not import checkout internals from outside the feature.",
},
],
}],
},
}
When a developer needs checkout data in the orders feature, the choice is clear: either orders imports from @/features/checkout public API, or the shared data moves to shared/ or a dedicated store. No reaching into features/checkout/hooks/useCartPersistence.ts.
Colocation: Keep Related Code Together
Colocation means placing code as close as possible to where it’s used. React’s component model naturally supports this — a component’s styles, tests, types, and sub-components live alongside it.
shared/ui/Button/
Button.tsx
Button.test.tsx
Button.module.css
Button.stories.tsx # if using Storybook
index.ts
For feature components with sub-components only used in one place:
features/products/components/ProductCard/
ProductCard.tsx
ProductCardImage.tsx # only used by ProductCard
ProductCardActions.tsx # only used by ProductCard
ProductCard.module.css
index.ts
Anti-pattern I refactor constantly: a global components/ folder with 200 files flat or grouped by type. ProductCard.tsx next to AdminUserTable.tsx next to Footer.tsx tells you nothing about ownership or dependencies.
Promote to shared/ui/ only when a component is used by three or more features. Until then, keep it in the feature.
Shared UI vs. Feature Components
The distinction between design system primitives and feature components is critical:
Shared UI (shared/ui/): Generic, reusable, zero business logic.
// shared/ui/Button/Button.tsx
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
export function Button({
variant = "primary",
size = "md",
isLoading,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={cn(styles.button, styles[variant], styles[size])}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <Spinner size="sm" /> : children}
</button>
);
}
Feature components: Domain-specific, compose shared UI.
// features/checkout/components/CheckoutButton.tsx
export function CheckoutButton() {
const { items, total } = useCart();
const { mutate, isPending } = useCheckout();
return (
<Button
variant="primary"
size="lg"
isLoading={isPending}
disabled={items.length === 0}
onClick={() => mutate()}
>
Pay {formatCurrency(total)}
</Button>
);
}
When a client asks “should this be a shared component?”, my test: remove all business context. If what’s left is generic enough for any app, it’s shared UI. If it references cart, products, or user roles, it’s a feature component.
State Management Architecture
Large projects need a clear state strategy. I use this hierarchy:
- Local state (
useState,useReducer) — UI state scoped to one component - Shared state via context — scoped to a feature subtree (cart, auth)
- Server state (TanStack Query) — API data with caching, invalidation, optimistic updates
- Global client state (Zustand) — only when truly cross-feature (theme, sidebar collapse)
// features/checkout/providers/CartProvider.tsx
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
Mount providers at the narrowest scope that works:
// app/(shop)/layout.tsx — cart only needed in shop routes
export default function ShopLayout({ children }) {
return (
<CartProvider>
<ShopHeader />
{children}
</CartProvider>
);
}
Avoid: a single Redux store with 40 slices imported everywhere. Prefer: feature-scoped state with TanStack Query handling server data. I’ve migrated three client projects from over-engineered Redux setups to this model and watched bundle size drop 30% with simpler mental models for the team.
API Layer Organization
Centralize HTTP configuration, scatter domain API functions by feature:
// shared/lib/api-client.ts
export async function apiClient<T>(
endpoint: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
credentials: "include",
});
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
return res.json();
}
// features/products/api/products.api.ts
import { apiClient } from "@/shared/lib/api-client";
import type { Product, ProductListParams } from "../types/products.types";
export const productsApi = {
list: (params: ProductListParams) =>
apiClient<{ items: Product[]; total: number }>(
`/products?${new URLSearchParams(params as Record<string, string>)}`,
),
getBySlug: (slug: string) =>
apiClient<Product>(`/products/${slug}`),
};
TanStack Query hooks wrap API functions in the same feature:
// features/products/hooks/useProducts.ts
export function useProducts(params: ProductListParams) {
return useQuery({
queryKey: ["products", params],
queryFn: () => productsApi.list(params),
staleTime: 60_000,
});
}
Naming Conventions That Scale
Consistent naming prevents the “which file is the real one?” problem:
| Type | Convention | Example |
|---|---|---|
| Components | PascalCase | ProductCard.tsx |
| Hooks | camelCase, use prefix | useCart.ts |
| Utilities | camelCase | formatCurrency.ts |
| Types | PascalCase, .types.ts suffix | checkout.types.ts |
| API modules | camelCase, .api.ts suffix | products.api.ts |
| Constants | SCREAMING_SNAKE in .constants.ts | MAX_CART_ITEMS |
| Tests | Same name + .test.tsx | ProductCard.test.tsx |
File names match their default export. ProductCard.tsx exports ProductCard, not Card or ProductCardComponent.
Avoid generic names at the feature level: utils.ts, helpers.ts, types.ts, constants.ts without domain prefix. In a large codebase, six files named utils.ts is six future bugs.
Route and Layout Organization
In Next.js App Router projects, I keep route files thin:
// app/(dashboard)/products/page.tsx
import { ProductListPage } from "@/features/products/components/ProductListPage";
export default function Page() {
return <ProductListPage />;
}
The page file handles routing concerns (metadata, params). The feature component handles everything else. This separation makes features testable without Next.js routing infrastructure and portable if the client ever changes frameworks.
Layout nesting mirrors user-facing structure:
app/
(marketing)/ # Public pages, marketing layout
(auth)/ # Minimal layout, centered forms
(dashboard)/ # Sidebar layout, auth required
(shop)/ # Shop header, cart provider
Each route group gets its own layout with appropriate providers scoped to that section.
Testing Structure Mirrors Source
Tests live next to the code they test:
features/checkout/
components/
CartSummary.tsx
CartSummary.test.tsx
hooks/
useCart.ts
useCart.test.ts
Integration tests that cross features live in test/integration/:
test/
integration/
checkout-flow.test.tsx
e2e/
checkout.spec.ts # Playwright
Shared test utilities in test/utils/ — custom render with providers, mock data factories:
// test/utils/render.tsx
export function renderWithProviders(ui: React.ReactElement) {
return render(
<QueryClientProvider client={testQueryClient}>
<AuthProvider user={mockUser}>
{ui}
</AuthProvider>
</QueryClientProvider>,
);
}
Documentation and Onboarding
Structure alone isn’t enough — document the decisions:
docs/
architecture.md # Overview of folder structure and rules
adding-a-feature.md # Step-by-step guide for new features
state-management.md # When to use what
I include an architecture.md in every client handoff explaining where things go and why. New developers read one document and start contributing on day one instead of day five.
For monorepos (growing trend on larger client projects):
apps/
web/ # Customer-facing Next.js app
admin/ # Admin dashboard
packages/
ui/ # Shared design system
config/ # ESLint, TypeScript configs
types/ # Shared type definitions
Turborepo or Nx manages the monorepo. Features that are app-specific stay in the app; truly shared code moves to packages.
Common Mistakes When Scaling React Projects
- Premature abstraction — building a generic
DataTablebefore you have three tables to compare - God components — 800-line files that handle fetching, formatting, rendering, and event handling
- Circular dependencies — features importing each other; fix with shared abstractions or event patterns
- Global everything — one context, one store, one utils file for the entire app
- Ignoring bundle analysis — features that import the entire lodash library for one function
- No import boundaries — ESLint rules exist; use them from week one, not after the refactor
Conclusion
Large React projects don’t fail because React is inadequate. They fail because structure wasn’t designed for growth. Feature-based organization, colocation, public APIs, scoped state, and enforced import boundaries give teams a codebase that scales with requirements instead of fighting them.
When I start a new client project expected to grow, I set up this structure on day one — even when day one only has three features. The cost of empty folders is zero. The cost of restructuring at 400 files is measured in weeks.
Your future self — and every developer who joins the project after you — will thank you for the boring, consistent folder structure that makes “where does this go?” a solved problem.
Key Takeaways
- Organize by feature, not by file type — colocate components, hooks, API calls, and types within domain folders.
- Expose feature public APIs through index.ts and enforce import boundaries with ESLint.
- Shared UI is generic; feature components contain business logic — promote to shared only after three or more consumers.
- Scope state narrowly — local state first, feature context second, TanStack Query for server data, global store last.
- Keep route files thin — composition and metadata in routes, logic in features.
- Mirror test structure to source with shared render utilities for provider-heavy components.
- Document architecture decisions so onboarding doesn’t depend on tribal knowledge.