B / React blog
How I Structure a Large React Application
An opinionated architecture guide to organizing features, domain logic, API clients, shared components, module APIs, dependencies, and tests in a production React application.
- React architecture
- Feature-based organization
- Module boundaries
- Frontend testing
A large React application does not become difficult because it has too many files. It becomes difficult when a change has no obvious owner, dependencies can point in any direction, and every useful abstraction slowly becomes global.
My preferred structure is feature-based, but the folders are not the architecture. The architecture is the set of decisions behind them: what a module owns, what it may import, what it exposes, and how code moves from local to shared as the product evolves.
I optimize for one practical question: when a developer receives a change, can they find its natural home, understand its dependencies, and modify it without learning the entire application?
This guide presents the default I use for production React applications. It is opinionated, but it is not a template to copy mechanically. The boundaries matter more than the exact names.
B-01
Why generic folders become dumping grounds
Many React applications begin with a structure like this:
src/
|-- components/
|-- hooks/
|-- services/
|-- types/
|-- utils/
`-- pages/It feels organized because every file has a technical category. The problem is that these categories say nothing about product ownership.
An order-history table, a checkout dialog, and the main navigation are all components. A currency formatter, a permission check, and a checkout calculation can all be called utilities. A data-fetching hook and a keyboard shortcut hook are both hooks, even though they belong to completely different parts of the product.
As the application grows, these folders develop predictable failure modes:
- Developers must search across several global directories to understand one feature.
- Similar names accumulate because the folder no longer provides business context.
- Private implementation details become convenient imports for unrelated features.
- Changes that should be local require edits in components, hooks, services, types, and utils.
- Nobody knows what is genuinely shared and what merely ended up in a shared location.
- Circular dependencies appear because there is no declared direction of travel.
The problem is not the words components, hooks, or utils. I still use those folders inside a bounded module. The problem is making technical type the highest-level organizing principle.
At the application level, I organize by responsibility and reason to change.
B-02
The structure I start from
For a substantial product, my default looks roughly like this:
src/
|-- app/
| |-- routes/
| |-- providers/
| `-- composition/
|-- domains/
| |-- orders/
| | |-- model/
| | |-- policies/
| | |-- tests/
| | `-- index.ts
| `-- accounts/
|-- features/
| |-- cancel-order/
| | |-- api/
| | |-- components/
| | |-- hooks/
| | |-- model/
| | |-- tests/
| | `-- index.ts
| `-- edit-billing-address/
`-- shared/
|-- api/
|-- ui/
|-- lib/
|-- config/
`-- testing/
e2e/The application layer composes routes, providers, and top-level workflows. Domain modules contain stable business concepts and rules. Feature modules implement user capabilities. Shared modules provide product-agnostic infrastructure and UI primitives. End-to-end tests sit outside src because they exercise the assembled system rather than one source module.
I do not create every directory on day one. A folder should exist because real code has a responsibility that needs a name. Empty architecture is only ceremony.
B-03
Feature modules own user capabilities
A feature is a behavior a user or another system can perform: cancel an order, invite a team member, update a payment method, export a report. I prefer capability names over vague areas such as dashboard or common.
A feature module may own:
- The React components used to perform that capability.
- Feature-specific hooks and interaction state.
- Validation and form mapping.
- API operations used only by the feature.
- Coordination between domain rules and infrastructure.
- Tests that protect the feature's behavior.
This keeps the files that change together close together. A developer working on order cancellation should not need to visit six global folders to trace one interaction.
Feature-based organization does not mean putting everything related to orders in one enormous orders folder. A domain is a business concept. A feature is an operation involving that concept. That distinction stops a large domain module from becoming the next dumping ground.
The route or application layer composes features. One feature should not reach into another feature's internal files to borrow a hook or component. If two features need the same business rule, that rule probably belongs to a domain module. If they need the same technical primitive, it probably belongs in shared.
B-04
Keep domain logic independent of React
Domain logic describes what the product allows and how business values behave. It should not depend on React rendering, browser APIs, a query cache, or the shape of an HTTP response.
export type Order = {
id: string;
status: "draft" | "confirmed" | "shipped" | "cancelled";
refundableUntil: Date | null;
};
export function canCancelOrder(order: Order, now: Date): boolean {
if (order.status === "shipped" || order.status === "cancelled") {
return false;
}
return order.refundableUntil === null || now <= order.refundableUntil;
}This rule can be used by a button, a route guard, a background workflow, or a test without mounting React or mocking fetch. The feature decides when to call it and how to present the result. The domain decides what is valid.
Not every condition deserves a domains folder. A display-only rule used by one component can stay inside that feature. I promote logic into a domain module when it represents shared business language, carries meaningful invariants, or must remain consistent across several workflows.
B-05
Put API clients at the edge of a module
I separate transport mechanics from product operations.
The shared API layer may own concerns such as the base URL, authentication headers, request cancellation, error normalization, tracing headers, and JSON parsing. It should not know how to cancel an order or invite a member.
The relevant feature or domain adapter owns endpoint paths, request and response DTOs, and the mapping between transport data and application types.
import { httpClient } from "@/shared/api";
type CancelOrderResponseDto = {
order_id: string;
status: "cancelled";
cancelled_at: string;
};
export async function cancelOrder(orderId: string) {
const response = await httpClient.post<CancelOrderResponseDto>(
`/orders/${orderId}/cancellation`,
);
return {
orderId: response.order_id,
status: response.status,
cancelledAt: new Date(response.cancelled_at),
};
}This boundary prevents wire formats from leaking through components and domain rules. If the backend renames a field or returns a new representation, the change is absorbed where the external contract enters the module.
I avoid one global api.ts or services.ts containing every endpoint. Those files centralize unrelated volatility. They look convenient until every team must edit the same module and every consumer can import every operation.
B-06
Give every module a public API
Each domain and feature exposes a small public surface through its root index.ts.
// features/cancel-order/index.ts
export { CancelOrderDialog } from "./components/cancel-order-dialog";
export { useCancelOrder } from "./hooks/use-cancel-order";
export type { CancelOrderResult } from "./model/cancel-order-result";Consumers import from the module boundary:
import {
CancelOrderDialog,
type CancelOrderResult,
} from "@/features/cancel-order";They do not import from private paths:
import { cancelOrder } from "@/features/cancel-order/api/cancel-order";The public API is an architectural decision. It tells other modules which contracts are stable and leaves the owner free to rename files, replace a state library, or reorganize internals without changing the rest of the application.
I do not add barrel files to every directory. That obscures origins and can create cycles. I use a single public entry point at a meaningful module boundary.
B-07
Enforce one dependency direction
The dependency model is intentionally simple:
app ----> features ----> domains
| | |
+-----------+--------------> shared- App may compose features, domains, and shared primitives.
- Features may depend on domain modules and shared infrastructure.
- Domains may depend on small, stable shared primitives, but not on features or app.
- Shared must not depend on product features or domains.
- A feature must not import another feature's private implementation.
When two features need to coordinate, I compose them in the application layer or extract the stable business behavior they share. I do not solve the problem with a deep relative import.
These rules should be enforceable. Path aliases improve readability, but they do not create boundaries. In a larger team I add restricted-import lint rules, module-boundary tooling, or package exports so an invalid import fails before code review. In a monorepo, the same rules can be expressed as package dependencies.
There are legitimate exceptions. A boundary rule should make unusual coupling visible, not force developers into elaborate workarounds. When I allow an exception, I record why the dependency exists, who owns it, and what would let us remove it later.
B-08
Shared is a promotion, not a starting point
The shared folder is the smallest part of this structure, not the largest.
Shared UI contains low-level, stable primitives such as Button, Dialog, Field, Stack, and semantic design tokens. Shared API contains transport infrastructure. Shared lib contains genuinely product-agnostic functions with clear names and contracts. Shared config contains application-wide configuration boundaries.
I do not move code into shared merely because it is used twice. Reuse count is weak evidence. Two features can look similar today and diverge next month.
I promote code when all three conditions are true:
- The consumers need the same behavior, not just similar markup.
- The abstraction has a stable name and contract.
- One owner can evolve it without understanding feature-specific workflows.
An OrderSummary is not automatically shared UI. It speaks the language of the order domain, so it may belong to that domain's public interface. A button is different: its behavior and accessibility contract apply across the product.
This rule keeps shared from becoming a prestigious name for code whose owner is unclear.
B-09
Keep tests with the boundary they protect
I organize tests by ownership, not by test-library vocabulary.
src/
|-- domains/orders/tests/
| `-- can-cancel-order.test.ts
|-- features/cancel-order/tests/
| |-- cancel-order-contract.test.ts
| `-- cancel-order-dialog.test.tsx
`-- shared/testing/
|-- render-application.tsx
|-- factories/
`-- server/
e2e/
`-- cancel-order.spec.tsPure domain tests live with the domain. Feature behavior and contract tests live with the feature. Shared testing contains infrastructure used by many modules: render wrappers, factories, deterministic builders, and network handlers. It must not become a second utils folder filled with business-specific fixtures.
End-to-end tests live at the repository level because they exercise deployed user journeys across module boundaries. Their folder reflects the scope of the test, not a preference for a particular runner.
A top-level tree divided into unit, integration, and component tests usually recreates the same navigation problem as components, hooks, and utils. Those labels describe how a test runs. They do not say who owns the behavior when it fails.
B-10
How I decide where new code belongs
When adding a file, I work through these questions in order:
- Which user capability or business concept owns this behavior?
- Is the code coordinating a workflow, enforcing a domain rule, or providing infrastructure?
- What is the narrowest module that can own it without duplication of meaning?
- Which modules need to import it today?
- What should those consumers be allowed to depend on?
- If the implementation changes, how far should the change propagate?
The last question is especially useful. If changing an API response forces edits across components, hooks, and pages, the transport boundary is leaking. If changing a feature's internal state breaks another feature, its public API is too wide. If changing a business rule requires hunting through UI components, the domain logic has no clear owner.
Architecture is revealed by the cost and reach of change.
B-11
Evolve toward the structure one slice at a time
I would not stop delivery to reorganize an existing application into this tree. Large restructures create churn without proving that the new boundaries are better.
Instead, I choose one meaningful vertical slice. I move its UI, interaction logic, API adapter, and domain rules behind one feature API. I fix the dependency direction around that slice, then use the next feature to test whether the pattern still holds.
The structure earns its place when routine changes become more local, private code stays private, and developers can explain why a dependency is allowed. If a boundary repeatedly fights the product, I change the boundary. Consistency is valuable, but protecting the wrong abstraction is not.
B-12
The final rule
I do not judge a React architecture by how symmetrical its folder tree looks. I judge it by whether it creates clear ownership and controlled change.
A strong structure tells a team where behavior belongs, prevents accidental coupling, keeps external contracts at the edges, and exposes only the APIs other modules are meant to use. Feature folders provide locality. Domain modules protect business rules. Shared code stays genuinely shared. Tests follow the behavior they protect. Dependency rules make the design enforceable.
That is the goal: not a perfect tree, but an application that can grow without requiring every developer to keep the whole system in their head.