React – Project Structure for a Large App

August 19, 202617 min readUpdated 9/18/2026

Where files go is the one decision in a React app that nobody makes on purpose. You scaffold with Vite, you get a src/ with App.tsx in it, you add a components/ folder because you have a component, and eighteen months later forty people are arguing about it in a pull request.

This lesson is about the version of that decision that matters: a codebase big enough that no one person has read all of it. React itself has no opinion here — it ships a component model and nothing else — so the structure is entirely yours, and it is the part of the app that is hardest to change later.

Start with what the pizza app actually does

Every other lesson in this track takes its snippets from one working application. Here is its whole src/:

src/
  App.tsx
  main.tsx
  components/     AppNavbar  CartDrawer  ErrorBoundary  Footer
                  PizzaBuilderModal  ProductCard  ProtectedRoute  StripePaymentForm
  context/        AuthContext  CartContext  MenuContext  ToastContext
  pages/          Home  Menu  Checkout  OrderConfirmation  Login  Register
                  Orders  Profile  InterviewQuestions
  pages/admin/    AdminLayout  AdminReports  AdminProducts  AdminToppings
                  AdminCrusts  AdminOrders  AdminUsers
  lib/            api.ts  adminApi.ts  money.ts  profileApi.ts  stripe.ts
  store/          index.ts  catalogSlice  ordersSlice  reportsSlice  usersSlice  apiFailure
  types/          index.ts
  data/           interviewQuestions.ts
  styles/         theme.scss  _tokens.scss
  assets/         hero.png  vite.svg

That is 43 TypeScript files and about 6,850 lines, organised by what each file is: components with components, pages with pages, types with types. Call it the type-based layout. It is what every tutorial shows you, including the early lessons of this one.

And at this size it is the right answer. You can hold 43 files in your head. When someone says "the cart drawer", you know it is in components/ without looking. There is no ceremony, no indirection, and a new developer is productive in an afternoon.

Do not let the rest of this lesson talk you out of that. The question is not whether type folders are bad — it is what specifically breaks when the app is ten times bigger, because that tells you what the replacement has to fix.

Where type folders start to hurt

Four things go wrong, and all four are visible in the pizza app already, in miniature.

One feature is spread across five folders

"The cart" is a single idea a product manager can describe in one sentence. Here is where it lives:

context/CartContext.tsx        the state, the reducer, the server sync   383 lines
components/CartDrawer.tsx      the slide-out UI                          140 lines
components/PizzaBuilderModal.tsx  the thing that adds items to it        263 lines
pages/CheckoutPage.tsx         where the cart is turned into an order    526 lines
lib/money.ts                   subtotal, tax, delivery fee, line totals   57 lines
types/index.ts                 CartItem, ServerCart, CartWriteRequest    (buried in 337)

Five folders, six files, and not one of them sits next to another. Now imagine the request "remove the cart and go to single-item instant checkout". You cannot answer "which files?" by looking — you have to grep, and grep will miss the type definitions that no longer have a reader.

This is the cost that scales. The number of folders you visit to change one feature stays roughly constant as the app grows, but the number of unrelated files you have to scroll past inside each folder does not. A components/ directory with 300 files in it is a directory you navigate with a fuzzy finder, which means the folder structure has stopped carrying information.

lib/ is where feature logic goes to hide

This is the interesting one, because it is a real bug in the pizza app's structure and it is measurable.

lib/money.ts is imported by 14 of those 43 files. That sounds like a well-used shared utility. Look at what each file actually asks for:

context/CartContext.tsx           { calculateTotals, type CartTotals }
components/CartDrawer.tsx         { formatMoney, lineTotal, unitPrice }
components/PizzaBuilderModal.tsx  { formatMoney, round2 }
pages/CheckoutPage.tsx            { formatMoney, lineTotal }

components/ProductCard.tsx        { formatMoney }
components/StripePaymentForm.tsx  { formatMoney }
pages/HomePage.tsx                { formatMoney }
pages/OrdersPage.tsx              { formatMoney }
pages/OrderConfirmationPage.tsx   { formatMoney }
pages/admin/AdminOrdersPage.tsx   { formatMoney }
pages/admin/AdminProductsPage.tsx { formatMoney }
pages/admin/AdminCrustsPage.tsx   { formatMoney }
pages/admin/AdminToppingsPage.tsx { formatMoney }
pages/admin/AdminReportsPage.tsx  { formatMoney }

Nine of the fourteen want one functionformatMoney, a three-line wrapper around Intl.NumberFormat that has nothing to do with pizza. The other five want the pricing rules. But they are the same module, so they are the same dependency:

// lib/money.ts — one module, two completely different jobs.

export function formatMoney(amount: number): string {   // genuinely shared: any app, any domain
  return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}

export const TAX_RATE = 0.085;                          // cart + checkout business rules
export const DELIVERY_FEE = 3.99;
export function calculateTotals(items: CartItem[], orderType: OrderType): CartTotals { /* … */ }

The result: the admin reports screen depends on the delivery fee. Not because it uses it — it does not — but because it needed a currency formatter and that is the module the formatter was in. Change how delivery is priced and the blast radius, as far as any tool can tell, includes five admin screens.

Nothing about type folders forces this. What they do is make it the path of least resistance: lib/ has no stated admission criteria, so anything vaguely reusable lands there, and each arrival quietly widens what the rest of the app depends on.

Ninety-nine relative imports

$ grep -rho "from '\(\.\./\)\+[^']*'" src | wc -l
99
$ grep -rho "from '\(\.\./\)\{2,\}[^']*'" src | wc -l
36

Ninety-nine imports reach out of their own folder, thirty-six of them by two or more levels, and the deepest are the honest ones — ../../store, ../../types, ../../lib/money, ../../context/ToastContext.

Counting dots is a small daily tax. The real cost is that ../../ encodes the current folder depth into every file, so moving a file breaks imports that had no opinion about where it lived. In a codebase where reorganising is normal, that friction is enough to stop people reorganising.

Nothing stops the wrong import

The pizza app is careful about one boundary in particular. Redux and the six admin screens are lazily loaded, and <Provider store={store}> is deliberately mounted inside AdminLayout rather than main.tsx, so Redux lands in the admin chunk and costs customers nothing — Code Splitting shows the 430 kB that saves.

That boundary is held up by a comment and by whoever reviews the pull request. Add import { store } from '../store' to HomePage.tsx and everything still compiles, the tests still pass, the lint still passes — and 37 kB of Redux moves into the bundle every customer downloads. You find out from a bundle-size graph, weeks later, if you are watching one.

An architecture that only exists in people's heads is not an architecture. It is an agreement, and agreements decay at exactly the rate your team changes.

The shape that scales: organise by feature

The fix is to make the folder structure say what the app does rather than what its files are. Three top-level directories:

src/
  app/            wiring: entry point, router, providers, global styles
  features/       one folder per thing the product does
  shared/         genuinely generic code, owned by nobody in particular

And inside a feature, the type folders come back — scoped to that feature, where they are useful again because there are eight files in them and not three hundred:

src/features/cart/
  components/       CartDrawer.tsx  PizzaBuilderModal.tsx  CartSummary.tsx
  hooks/            useCart.ts
  api/              cartApi.ts
  model/            cartReducer.ts  CartProvider.tsx  pricing.ts
  types.ts          CartItem  ServerCart  CartWriteRequest  CartTotals
  index.ts          the feature's public API — see below

That is the whole idea: a feature is a vertical slice. Its UI, its state, its network calls, its types and its business rules are one directory, because they change together. The "remove the cart" request becomes rm -r src/features/cart plus whatever the compiler then complains about — and the compiler complaining is the point.

A feature is not a route and it is not a component. checkout is a feature; Button is not; /order-confirmation/:orderId is a route that the orders feature happens to own. The test is whether a non-engineer would name it when describing the product.

The three rules that make it work

The folders are the easy half. Enterprise codebases that fail at this usually did the folders and skipped the rules, and ended up with the same tangle in prettier packaging.

1. A feature owns its whole vertical slice

If a type, a fetch call or a pricing rule is used by one feature, it lives in that feature — not in a global types/ or lib/. The pizza app's types/index.ts is 337 lines imported by 23 files; split by owner, CartItem and ServerCart go to features/cart/types.ts, Order and OrderStatus to features/orders/, and only the handful that genuinely cross features — UUID, Page<T>, ApiErrorBody — stay shared.

Duplication is cheaper than the wrong abstraction here. Two features with a similar-looking Address type are usually two types that are about to diverge. Wait for the third use before promoting anything to shared/.

2. Features do not reach into each other

Every feature exports a public API from its index.ts, and that is the only thing other features may import:

// features/cart/index.ts — the contract. Everything not listed here is private.
export { CartProvider, useCart } from './model/CartProvider';
export { CartDrawer } from './components/CartDrawer';
export type { CartItem, CartTotals } from './types';

// Deliberately NOT exported: cartReducer, pricing.ts, cartApi.ts, CartSummary.
// They are implementation. Nobody outside the cart should be able to depend on them.
// features/checkout/CheckoutPage.tsx
import { useCart } from '@/features/cart';              // ✅ the public API
import { useCart } from '@/features/cart/model/CartProvider';  // ❌ reaching inside

Two features that need the same thing is a signal, not a problem to route around: either it belongs in shared/, or one of them should be asking the other through its public API, or — most often — they are actually one feature that has been split along the wrong line.

3. Dependencies point one way

app/  ───────►  features/  ───────►  shared/

app      may import anything.                 It is the wiring; that is its job.
features may import shared, never app,
         and never another feature's internals.
shared   may import NOTHING from features.    This is the rule that decays first.

That last line is where lib/money.ts went wrong. It is nominally shared and it imports CartItem, which points backwards. Under the one-way rule the fix is forced and obvious:

shared/lib/currency.ts     formatMoney, round2          // no domain types, importable by anyone
features/cart/model/pricing.ts  TAX_RATE, DELIVERY_FEE, unitPrice, lineTotal, calculateTotals

The nine files that only wanted a formatter now depend only on a formatter, and the admin reports screen stops depending on the delivery fee.

Mapping the pizza app onto it

Concretely, for the files above:

FeatureAbsorbs
features/menu/MenuContext, MenuPage, ProductCard, the catalogue types
features/cart/CartContext, CartDrawer, PizzaBuilderModal, the pricing half of money.ts, the cart types
features/checkout/CheckoutPage, StripePaymentForm, lib/stripe.ts
features/auth/AuthContext, LoginPage, RegisterPage, ProtectedRoute, tokenStore
features/orders/OrdersPage, OrderConfirmationPage, the order types
features/profile/ProfilePage, lib/profileApi.ts, Address and PaymentMethod
features/admin/all seven admin pages, all four Redux slices, store/, lib/adminApi.ts
shared/the api client, ErrorBoundary, ToastContext, formatMoney, UUID, Page<T>, ApiErrorBody
app/main.tsx, App.tsx, AppNavbar, Footer, styles/

Three things in that table are worth arguing about, and the arguments are the useful part.

features/admin/ is one feature, not six. The six CRUD screens share the Redux store, the admin API client and the layout, and they are always changed together. Splitting them would create six folders that all reach into each other — rule 2 violated by construction.

AppNavbar lives in app/, not shared/. It reads useAuth, useCart and useMenu — it is downstream of three features at once, which is exactly what app/ is for. Putting it in shared/ would break the one-way rule on day one.

ToastContext is shared, CartContext is not. Toasts are a generic notification mechanism with no domain knowledge; the cart is the domain. Same file extension, same context/ folder today, opposite sides of the boundary.

Path aliases

Feature folders are deeper than type folders, so they make ../../ worse before they make it better. Fix that first — it is two small edits:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
});
// tsconfig.app.json — the bundler and the typechecker are separate resolvers.
// Configure one and the app runs but the editor shows red; configure the other
// and the types are fine but the build fails. Both, always.
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Now an import says where something is rather than how far away it is:

import { formatMoney } from '../../lib/money';     // before
import { formatMoney } from '@/shared/lib/currency'; // after — same from any depth

One alias, not six. Teams that add @features, @components, @hooks, @utils end up needing a lookup table to read an import statement. A single @/ root keeps the path readable and keeps vite.config.ts, tsconfig.json, the test runner and Storybook in step.

Make the boundary a build error

This is the step that separates an architecture from a convention, and it is the one most teams skip. Rules 2 and 3 are mechanical — a linter can check them, so a linter should.

// eslint.config.js  —  flat config, with eslint-plugin-import installed.
import importPlugin from 'eslint-plugin-import';

export default [
  {
    files: ['src/**/*.{ts,tsx}'],
    // The plugin has to be REGISTERED here, or the `import/` rule below
    // fails to resolve and ESLint exits with "Definition not found".
    plugins: { import: importPlugin },

    // ⚠️ Without this the rule below silently does nothing for aliased
    // imports. See the note after this block — it is not optional.
    settings: { 'import/resolver': { typescript: { project: './tsconfig.json' } } },

    rules: {
      'import/no-restricted-paths': ['error', {
        zones: [
          {
            // shared/ must not know that features exist.
            target: './src/shared',
            from: './src/features',
            message: 'shared/ cannot import from features/.',
          },
          {
            // features/ must not reach back into the app wiring.
            target: './src/features',
            from: './src/app',
            message: 'features/ cannot import from app/.',
          },
        ],
      }],

      // A feature may import another feature's index.ts, and nothing deeper.
      // (Core ESLint rule — no plugin needed for this one.)
      'no-restricted-imports': ['error', {
        patterns: [{
          group: ['@/features/*/*'],
          message: 'Import the feature root, not its internals.',
        }],
      }],
    },
  },
];

The resolver line is load-bearing

That settings line is the one to get right, because leaving it out fails in the worst possible way: quietly, and only for the imports you care about.

import/no-restricted-paths works on resolved paths. It has to turn the import string into a file on disk before it can decide which zone that file is in. Relative imports resolve out of the box, so the rule fires. An aliased import does not — and an unresolvable import is not an error to this rule, it is simply skipped:

# src/shared/lib/currency.ts — both lines break the same rule.

import { useCart } from '../../features/cart';   # caught
import { useCart } from '@/features/cart';       # NOT caught, no resolver configured
$ npx eslint src
✖ 1 problem

So you adopt @/ aliases in one section, add the boundary rule in the next, watch it catch a violation in the test you wrote, and ship an enforcement rule that is a no-op against every import written in the style you just standardised on. Install eslint-import-resolver-typescript, point it at the tsconfig that defines paths, and both lines report:

$ npx eslint src
  1:25  error  Unexpected path "@/features/cart" imported in restricted zone.
               shared/ cannot import from features/   import/no-restricted-paths
✖ 2 problems

Test the rule by writing a violation on purpose and confirming the error, every time you add a zone. A boundary rule that has never failed is indistinguishable from one that cannot fail.

Cross-feature imports need one more turn of the screw to be airtight — the pattern above stops deep imports but still lets any feature import any other feature's public API. Teams that want the stricter version generate a per-feature override, or reach for eslint-plugin-boundaries, which is built for exactly this and lets you declare element types and an allowed-dependency matrix.

The pizza app does not have this. It lints with oxlint, which is fast and catches real bugs but is not configured with any import-boundary rule, so every boundary described above is currently held up by review alone. That is fine for 43 files and one author. It is not fine for 400 files and twenty authors, and the day it stops being fine is not announced.

One more option worth knowing at the top end: TypeScript project references, or a real monorepo with each feature as a workspace package. Then the boundary is enforced by the module resolver rather than a lint rule, a feature's package.json lists what it may depend on, and a violation is not lintable-away. It is heavier than most apps need — but if your "enterprise app" is genuinely several teams shipping on independent cadences, this is the line past which folder conventions stop being enough.

Where state goes once you have features

Structure answers a question that Context and Redux leave open: not "which state tool", but "where does this state live". Feature folders make the answer mostly mechanical.

ScopeWhere it goesIn the pizza app
One componentuseState, in the componentcartOpen in App.tsx
One featurea provider inside that feature, exported via its hookCartProvider + useCart
Several featuresshared/, mounted in app/AuthProvider, ToastProvider
One big subsystema store inside that feature, provided by its layoutRedux inside AdminLayout
Server dataa query cache, not a store

Two of those rows carry most of the weight.

The Redux row is a structural decision, not a state-management one. Keeping the store inside features/admin/ rather than at the root is what makes it possible for it to be lazily loaded, and what stops a customer-facing component from dispatching into it. Where a provider is mounted decides both the bundle and the boundary.

The last row is the one people get wrong. Most of what ends up in a global store is not application state — it is a cached copy of the server, with loading flags and refetch logic hand-written around it. That is what TanStack Query or RTK Query are for, and adopting one typically deletes more code than it adds. The pizza app hand-rolls it in MenuContext and the admin slices, which is instructive to read and not what a new enterprise app should do.

Tests and stories next to the code

A vertical slice should be vertical all the way down:

features/cart/components/
  CartDrawer.tsx
  CartDrawer.test.tsx
  CartDrawer.stories.tsx

A parallel tests/ tree that mirrors src/ guarantees two things: the mirror drifts, and moving a file means editing two paths. Colocated tests move with the code, and a feature folder you can delete in one command is one where the tests go with it.

End-to-end tests are the exception and belong at the root — the pizza app keeps them in e2e/, because a checkout journey crosses menu, cart, auth and orders and is owned by none of them.

What not to do

Atomic design as a folder structure. atoms/, molecules/, organisms/ is a vocabulary for designers that becomes an unwinnable argument for engineers — every code review acquires a debate about whether a thing is a molecule. It is the type-based layout with worse names.

A folder per component. Button/Button.tsx, Button/Button.test.tsx, Button/Button.module.css, Button/index.ts — four files and two levels for something that is sometimes twenty lines. Do it when a component genuinely has several files; do not do it by policy.

A utils/ folder. Same failure mode as lib/, one step further along: a module named for what it is not. Name modules for what they do — currency.ts, dates.ts, pricing.ts — and it stays obvious when something does not belong.

Structuring for a scale you do not have. Seven feature folders each containing one component, wrapped in barrels, behind five aliases, is not enterprise-ready — it is a small app with a large tax on it.

And a note on barrel files

Rule 2 needs an index.ts per feature, and that is the right number of barrels. Do not extend the habit downwards. An index.ts in every sub-folder re-exporting everything beneath it creates import cycles that are miserable to unpick, and makes it easy to pull a whole feature into a chunk that wanted one function from it — precisely the code splitting you worked for. Keep barrels at the feature boundary, where they are a contract, and nowhere else, where they are just indirection.

When to actually move

Not on day one. A new app should start as the pizza app is today — flat, type-based, obvious — and restructure when the symptoms show up:

  • A components/ or pages/ folder you scroll to navigate.
  • More than one team shipping in the same repo.
  • Changes that routinely touch four folders to do one thing.
  • A lib/ or utils/ module that half the app imports for different reasons — the money.ts symptom.
  • A code review where somebody has to explain, again, why a given import is not allowed.

The last one is the real trigger. It means the architecture exists only in the heads of the people who happen to be reviewing, and the cost of writing it down as a lint rule is now lower than the cost of not.

Migrate one feature at a time. Add the alias, create src/features/, move the most self-contained feature into it with its types and its API calls, give it an index.ts, add the lint rule for that feature alone, and ship. A big-bang reorganisation is a pull request nobody can review and a merge conflict with everything in flight.

That is the track

Twenty-seven lessons, from creating a project to shipping one and organising it for a team. Along the way: components, JSX, props and state; effects, refs and error boundaries; context, reducers, custom hooks and Redux; routing, memoisation, code splitting, styling, and the folder structure that holds it together.

Every example came from the same working application, which is also the best next step available — build something with a cart, a form, a list and a route in it, and the parts that felt abstract stop being abstract.

For reference beyond this track, react.dev is genuinely excellent and worth reading rather than only searching.

Next

Interview Questions — twenty-four senior-level questions covering the whole track, including a section on Context, with the five that matter most flagged.