React Architecture: Scaling from a Small Project to a Feature-Based Codebase

Learn how to structure a React + TypeScript project's folders and dependencies as it grows — from a simple components/pages/hooks/utils layout to a feature-based architecture with clear dependency direction — and how to avoid giant, unmaintainable components.

A React project's folder structure is not just bookkeeping — it is a statement about how the codebase is allowed to depend on itself. A small app can get away with a flat, generic structure because there simply isn't much to get confused about. A large app with dozens of contributors needs an architecture that actively prevents accidental coupling between unrelated parts of the product, because nothing else will. This lesson walks through both ends of that spectrum and the principles (separation of concerns, dependency direction, component boundaries) that connect them.

Small project structure: components, pages, hooks, utils

For a small app — a handful of screens, one or two developers, no strong domain boundaries yet — a simple, generic folder structure is the right call: components/ for reusable UI pieces, pages/ (or routes/) for top-level screens the router renders, hooks/ for shared custom hooks, and utils/ for framework-agnostic helper functions (formatting, validation, small pure functions).

This structure organizes files by their technical role (is this a component? a hook? a page?) rather than by what part of the product they belong to, which is fine at small scale because there usually is not yet a meaningful notion of 'parts of the product' — everything is still close enough together that a developer can hold the whole app in their head.

The failure mode of this structure is that it does not scale: once components/ has 150 files and it is unclear whether Button.tsx is used by three features or thirty, and whether InvoiceTable.tsx is safe to change without breaking the dashboard, the flat structure has stopped helping and started hiding information the team actually needs.

Large project structure: feature-based architecture

A large, multi-team codebase benefits from organizing by feature (what the code is for) instead of by technical role (what kind of file it is). A common layered version of this looks like: app/ (routing, providers, global setup), features/ (self-contained product capabilities like invoicing/ or auth/, each with its own components, hooks, and state), shared/ (generic reusable UI and utilities with no feature-specific knowledge), entities/ (core domain objects like User or Order and the logic around them), pages/ (route-level composition of features), services/ (API clients and data-fetching logic), and infrastructure/ (environment config, logging, analytics wiring, and other cross-cutting technical concerns).

The key benefit is that each feature/ folder becomes close to a self-contained module: everything the invoicing feature needs — its components, its local state, its API calls — lives together, so a developer working on invoicing rarely needs to open files outside features/invoicing/ and shared/. This is the same idea as 'package by feature, not by layer' from other ecosystems, applied to a React app.

This structure is not mandatory boilerplate to copy on day one — it is what a codebase should grow into once the small, flat structure starts hurting. Introducing features/ before there are genuinely distinct features just adds ceremony; the right time to adopt it is when you can point at real product boundaries (billing vs. messaging vs. settings) that keep colliding in a flat components/ folder.

Dependency direction and component boundaries

A feature-based architecture only pays off if the team enforces a dependency direction: shared/ and entities/ should never import from features/, features/ should never import from other features/ directly (they compose through pages/ or through explicit, minimal public APIs), and infrastructure/ and services/ sit low in the stack so almost everything can depend on them, but they depend on almost nothing above. Drawing this as layers — infrastructure and shared at the bottom, entities above that, features above that, pages and app at the top — makes it visually obvious which imports are 'going the wrong way'.

A component boundary is the explicit, intentional interface a component or feature exposes to the rest of the app — its props, and (for a feature folder) an index.ts barrel file that re-exports only what other parts of the app are meant to use, keeping the feature's internal components and hooks private. Violating a boundary usually looks like reaching three folders deep into another feature to import one internal helper component, which quietly creates a hidden dependency nobody sees in a code review of either feature.

'Avoid giant components' is the same principle at the component level rather than the folder level: a 600-line component that fetches data, manages five pieces of state, and renders a complex layout is really several responsibilities wearing one trenchcoat. Splitting it by responsibility — a custom hook for the data-fetching and state, smaller presentational components for the layout pieces — keeps each piece separately testable and separately reasoned-about, which is the same separation-of-concerns benefit the folder structure is trying to achieve at a larger scale.

Component API design and versioning at scale

In a large codebase, a shared component's prop types are a contract other teams write code against, so changing them is not free. Treat a widely-used component's exported props type the way you would treat a public library API: adding an optional prop is safe, but renaming or removing a required prop is a breaking change that needs a deliberate migration (a codemod, a deprecation warning, or a new component version living alongside the old one until callers migrate).

A useful discipline is exporting components through an explicit barrel (index.ts) per feature or shared package, so the 'public API' of that folder is a short, deliberate list rather than 'anything anyone can reach by typing the right relative import path'. TypeScript's own module resolution does not enforce this for you — it is a team convention, sometimes backed by lint rules (such as forbidding deep imports past a feature's index.ts) that turn the convention into something CI actually checks.

Small vs. large project folder layouts

Dependency direction flows upward: infrastructure/shared -> entities -> features -> pages/app. Nothing lower should import from something higher. Each feature's index.ts is its component boundary — the only files other parts of the app are meant to import from.

// Small project
src/
  components/     // reusable UI pieces (Button, Modal, Card)
  pages/           // route-level screens (HomePage, SettingsPage)
  hooks/           // shared custom hooks (useDebounce, useAuth)
  utils/           // pure helper functions (formatCurrency, isValidEmail)

// Large project — feature-based architecture
src/
  app/             // routing, top-level providers, global setup
  features/
    invoicing/
      components/
      hooks/
      api.ts
      index.ts     // public exports only — the feature's boundary
    auth/
      ...
  shared/          // generic UI + utils, no feature-specific knowledge
  entities/        // core domain types/logic (User, Order, Invoice)
  pages/           // compose features into routes
  services/        // API clients, data-fetching layer
  infrastructure/  // env config, logging, analytics

Code Examples

The invoicing feature exposes a deliberately small public surface through index.ts; any other feature importing its internal InvoiceRow component directly creates a hidden dependency that the invoicing team never agreed to support and can break without warning.

import { InvoiceRow } from "@/features/invoicing/components/InvoiceRow"; // internal, not exported

Common mistake: Deep-importing past a feature's index.ts because 'it's just one small component' is exactly how dependency direction erodes over time — a lint rule blocking deep imports outside a feature's own folder is the usual fix.

Moving the data-fetching and state into a dedicated hook, and the layout pieces into their own presentational components, turns one unreadable component into several small, independently testable pieces — the same separation-of-concerns idea the folder architecture applies at the whole-app level.

// features/invoicing/index.ts — the ONLY file other code should import from
export { InvoiceList } from "./components/InvoiceList";
export { useInvoices } from "./hooks/useInvoices";
export type { Invoice } from "./types";
// InvoiceRow, formatInvoiceStatus, and other internals stay unexported

// pages/BillingPage.tsx — correct: imports the public boundary
import { InvoiceList, useInvoices } from "@/features/invoicing";

function BillingPage() {
  const { invoices, isLoading } = useInvoices();
  return isLoading ? <Spinner /> : <InvoiceList invoices={invoices} />;
}

// features/reporting/SomeReport.tsx — WRONG: reaches past the boundary
import { InvoiceRow } from "@/features/invoicing/components/InvoiceRow"; // internal, not exported
// Before: one 300-line component doing everything
function Dashboard() {
  const [invoices, setInvoices] = useState<Invoice[]>([]);
  const [users, setUsers] = useState<User[]>([]);
  const [filter, setFilter] = useState("");
  useEffect(() => { fetchInvoices().then(setInvoices); }, []);
  useEffect(() => { fetchUsers().then(setUsers); }, []);
  // ...200 more lines mixing data fetching, filtering logic, and layout JSX
}

// After: responsibilities separated
function useDashboardData() {
  const invoices = useInvoices();
  const users = useUsers();
  return { invoices, users };
}

function Dashboard() {
  const { invoices, users } = useDashboardData();
  return (
    <DashboardLayout>
      <InvoiceSummary invoices={invoices} />
      <UserActivityPanel users={users} />
    </DashboardLayout>
  );
}

Dependency direction in a feature-based architecture

Arrows point downward only: a lower layer never imports from a higher one, and features never import each other directly.

app / pages
               |
           features/*
         (invoicing, auth, ...)
               |
            entities
               |
     shared        services
               \    /
            infrastructure

Important Rules

Start with a small, generic structure (components/pages/hooks/utils) — do not introduce feature folders before real product boundaries exist.

In a large codebase, organize by feature (what the code is for) rather than by technical role (what kind of file it is).

Enforce a one-way dependency direction: shared/entities/infrastructure sit low and are depended on, features and pages sit high and do the depending — never the reverse.

Expose each feature's public surface through a deliberate barrel export (index.ts); treat deep imports into another feature's internals as a bug, not a convenience.

Split a component the moment it mixes multiple responsibilities (data fetching, business logic, layout) — extract a custom hook for the logic and keep the component focused on rendering.

Where this shows up

Large production React codebases at companies with multiple product teams (e-commerce platforms with separate checkout, catalog, and account teams; SaaS dashboards with separate billing, admin, and reporting teams) almost universally converge on some feature-based variant of this structure, often paired with lint rules (such as eslint-plugin-boundaries or eslint-plugin-import's restricted-paths) that turn 'features shouldn't import each other's internals' from a convention into something CI enforces automatically.

// shared/Button.tsx importing from a specific feature — inverts the dependency direction import { useInvoiceTheme } from "@/features/invoicing/hooks/useInvoiceTheme"; export function Button() { const theme = useInvoiceTheme(); // ... }

// shared/Button.tsx stays feature-agnostic and accepts styling as props/context export function Button({ variant = "default" }: { variant?: "default" | "danger" }) { // reads a generic, app-wide theme, never a specific feature's hook }

A shared/ component importing from a specific feature inverts the intended dependency direction: shared code is supposed to be usable by every feature, so it can never depend on any one of them without creating a hidden coupling that breaks the moment that feature changes or is removed.

[multiple-choice] Why is a feature-based folder structure usually a poor choice for a brand-new, small app? A. React does not support nested feature folders B. It adds organizational ceremony before the app has real, distinct product boundaries to organize around C. Feature folders are slower to compile than a flat structure D. TypeScript requires all files to be in one folder

B. It adds organizational ceremony before the app has real, distinct product boundaries to organize around — A small app usually does not yet have meaningfully distinct product areas, so organizing by feature just adds structure without a corresponding benefit — the simpler components/pages/hooks/utils layout fits better until real boundaries emerge.

[multiple-choice] In a feature-based architecture, which statement correctly describes the intended dependency direction? A. shared/ and entities/ should be free to import from any feature they need B. features/ should import each other directly whenever they need to share logic C. Lower layers like shared/, entities/, and infrastructure/ should never import from features/ or pages/ D. Dependency direction does not matter as long as the app compiles

C. Lower layers like shared/, entities/, and infrastructure/ should never import from features/ or pages/ — Lower layers (shared, entities, infrastructure) are meant to be depended upon, not to depend on higher layers like features or pages — reversing that direction creates hidden coupling that makes the codebase fragile to change.

[multiple-choice] What is the purpose of a feature's index.ts barrel export in a large React codebase? A. It improves the app's runtime performance B. It is required by the React compiler C. It defines the feature's deliberate public API, keeping internal components and hooks private to the feature D. It automatically generates TypeScript types for the feature

C. It defines the feature's deliberate public API, keeping internal components and hooks private to the feature — The barrel export is a component boundary — it makes explicit which parts of a feature other code is allowed to depend on, while internals stay private and free to change without breaking other features.

Given a small app with src/components/, src/pages/, src/hooks/, src/utils/ that has grown to include two clearly distinct product areas (say, 'messaging' and 'billing'), sketch the feature-based folder structure you would migrate it to, and identify which existing files would move into shared/ versus into each feature/ folder.

features/messaging/{components,hooks,api.ts,index.ts}, features/billing/{components,hooks,api.ts,index.ts}; a generic Button and formatDate (used by both) move to shared/; formatCurrency (only used by billing) moves inside features/billing/ unless another feature also needs it, in which case it belongs in shared/ instead.

Anything used by both messaging and billing (a generic Button, a formatCurrency util used only by billing, a formatDate util used by both) tells you whether it belongs in shared/ or inside one feature/.