New: Master React Architecture at Scale! Read our brand new guide on Designing Large React Applications.Read Architecture Guide
FrontendPrep
Back to Guides
reactHard35 min read

Designing Large React Applications: Architecture at Scale

A definitive guide to structuring, scaling, and architecting large React and TypeScript codebases. Learn folder layout, API layer design, state orchestration, testing, and performance optimization.

Arvind M
Arvind MLinkedIn

Designing Large React Applications: Architecture at Scale

As a React application scales, the main challenge shifts from "how to write code" to "how to organize code so that twenty developers can work in the same repository without stepping on each other's toes."

Without a deliberate architecture, projects reach a point where:

  • A change to a component in one corner of the app breaks a feature in another.
  • Refactoring a single API call requires changing thirty files.
  • Developers spend hours resolving merge conflicts due to bloated global files.

This guide provides an opinionated blueprint for structuring and scaling React applications using TypeScript and modern development patterns.


1. Introduction: The Reality of "Large" Applications

What "Large" Actually Means

In frontend engineering, a "large" application is defined by three vectors:

  1. Team Size: More than 10 developers writing code concurrently on the same repository.
  2. Codebase Size: Over 100,000 lines of code (LOC), hundreds of files, and multiple third-party integrations.
  3. Longevity: A project intended to live for 3 to 5+ years, requiring continuous maintenance and onboarding of new developers.

Why Structure Decisions Compound

In the first few months of a project, any file structure works. The codebase is small enough to fit in a single developer's head. However, structural flaws act as debt with compound interest.

If it takes 2 minutes of search time to locate a file in a messy structure, a team of 15 developers making 10 changes a day wastes over 60 hours a year just navigation-searching. More critically, high coupling leads to spaghetti dependencies—where a simple button refactor can ripple through authentication, dashboards, and reporting modules.

Prerequisites & Assumptions

This guide assumes you are building a modern single-page application (SPA) or hybrid Next.js/Vite project utilizing:

  • React (v18 or v19)
  • TypeScript (Strict mode enabled)
  • Modern Bundlers (Vite, Webpack, or Turbopack)
  • Client-side State Management

2. Folder Structure: The Feature-Based Blueprint

The Problem: Layer-Based Organization

Most junior projects start with a layer-based structure (also called "technical-type organization"):

src/
  components/
    Button.tsx
    ProfileCard.tsx
  hooks/
    useAuth.ts
    useProjects.ts
  pages/
    Dashboard.tsx
    Settings.tsx
  services/
    api.ts

Why this breaks at scale: When you build a feature like "User Comments," you have to edit files in components/, hooks/, pages/, and services/. This context-switching is cognitively expensive. When multiple developers work on different features, they end up editing the same shared folders, resulting in constant merge conflicts.

Instead of grouping by technical type, we group by business domain. A feature module is a self-contained unit of functionality.

Here is the concrete, copy-pasteable directory tree for a large React application:

src/
├── assets/                    # Static assets (images, global fonts)
├── components/                # Core design system components (buttons, modals, tooltips)
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   └── index.ts
│   └── Input/
├── core/                      # Global singletons & configurations
│   ├── config.ts              # Typed environment variables
│   └── telemetry.ts           # Logging and analytics setup
├── features/                  # Self-contained domain-driven feature modules
│   ├── auth/                  # Authentication domain
│   │   ├── api/               # API requests for login/logout/signup
│   │   ├── components/        # Feature-specific UI (LoginForm)
│   │   ├── hooks/             # Local authentication hooks
│   │   ├── types/             # Domain TS interfaces (User, Session)
│   │   └── index.ts           # Public API entry point
│   ├── project-board/         # Project management domain
│   └── billing/               # Billing and subscription domain
├── layouts/                   # Shared shell layouts (SidebarLayout, AuthLayout)
├── lib/                       # Third-party SDK wrappers & configurations
│   ├── api-client.ts          # Axios / Fetch client singleton
│   ├── query-client.ts        # TanStack Query client setup
│   └── logger.ts              # Local logging utility wrapper
├── routes/                    # Routing configuration and lazy routing setup
├── store/                     # Global cross-cutting state (Zustand, Redux)
├── types/                     # Shared fallback TypeScript types
└── utils/                     # Pure helper functions (formatters, validation helpers)

3. Feature Modules: Isolation and Boundaries

The Problem: Circular and Spaghetti Imports

If features/project-board imports components directly from features/billing, and features/billing imports types from features/project-board, you create a circular dependency loop. This slows down the bundler, breaks hot module reloading (HMR), and prevents you from splitting code effectively.

The Solution: Explicit Public API Boundaries

Every feature folder must have an index.ts file acting as a gatekeeper (a barrel file). Only items exported from index.ts can be used by other parts of the application. Everything else inside the feature directory is considered private implementation detail.

Feature Directory Layout Example (src/features/project-board/):

project-board/
├── api/
│   └── getProjects.ts
├── components/
│   ├── Board.tsx
│   └── ProjectCard.tsx
├── types/
│   └── index.ts
├── index.ts                   # Public API boundary

The Gatekeeper file (src/features/project-board/index.ts):

// Export only components that other features or pages need
export { Board } from "./components/Board";
 
// Export types that need to be referenced globally
export type { Project, ProjectStatus } from "./types";
 
// Keep internals like ProjectCard or getProjects private to the feature

Enforcing Boundaries with ESLint:

To prevent developers from bypassing the index.ts file and importing deep files, add this ESLint configuration (using eslint-plugin-import):

{
  "rules": {
    "no-restricted-imports": [
      "error",
      {
        "patterns": [
          {
            "group": ["@/features/*/*"],
            "message": "Importing feature internals directly is forbidden. Use the public API exported in '@/features/*' instead."
          }
        ]
      }
    ]
  }
}

4. Component Design: Composition over Configuration

The Problem: Prop-Heavy "God" Components

In large codebases, developers often build massive components that try to cover every edge case by piling on conditional props:

// ❌ BAD: A prop-heavy component that is difficult to extend
<Card 
  title="Project Card"
  showAvatar={true}
  onButtonClick={handleEdit}
  buttonText="Edit"
  variant="compact"
  isLoading={false}
  hasBorder={true}
/>

If a new screen needs a card with an icon instead of an avatar, you have to add showIcon and iconType props, bloating the component code and making it fragile.

The Solution: Component Composition

Break components down into small, composable subcomponents, utilizing React's children pattern.

Example: Designing a Composable Card Component

// src/components/Card/Card.tsx
import React, { ReactNode } from "react";
 
interface CardProps {
  children: ReactNode;
  className?: string;
}
 
export const Card = ({ children, className = "" }: CardProps) => {
  return (
    <div className={`rounded-xl border border-card-border bg-card-bg p-5 shadow-xs ${className}`}>
      {children}
    </div>
  );
};
 
// Sub-components
Card.Header = function CardHeader({ children }: { children: ReactNode }) {
  return <div className="border-b border-card-border/60 pb-3 mb-4">{children}</div>;
};
 
Card.Body = function CardBody({ children }: { children: ReactNode }) {
  return <div className="text-sm text-text-muted leading-relaxed">{children}</div>;
};
 
Card.Footer = function CardFooter({ children }: { children: ReactNode }) {
  return <div className="mt-4 flex items-center justify-end gap-2">{children}</div>;
};

Usage in a Feature:

// src/features/project-board/components/ProjectCard.tsx
import { Card } from "@/components/Card/Card";
import { Button } from "@/components/Button/Button";
 
export const ProjectCard = ({ name, description }: { name: string; description: string }) => {
  return (
    <Card>
      <Card.Header>
        <h3 className="font-bold text-foreground">{name}</h3>
      </Card.Header>
      <Card.Body>
        <p>{description}</p>
      </Card.Body>
      <Card.Footer>
        <Button size="sm">Open Project</Button>
      </Card.Footer>
    </Card>
  );
};

5. The API Layer: Decoupling Data Fetching

The Problem: Fetch Calls Scattered in Components

Fetching data inside components using useEffect creates spaghetti code:

// ❌ BAD: Mixing API logistics, state, and UI rendering
useEffect(() => {
  setLoading(true);
  fetch(`/api/projects`, { headers: { Authorization: `Bearer ${token}` }})
    .then(res => res.json())
    .then(data => {
      setProjects(data);
      setLoading(false);
    })
    .catch(err => setError(err));
}, []);

This is impossible to unit test without mocking the global fetch API, lacks request deduplication, and duplicates endpoint URLs across features.

The Solution: A Strongly-Typed Centralized Client + Query Hooks

Decouple API integration into three distinct layers:

  1. API Client Setup: A unified HTTP instance (Axios or Fetch client).
  2. API Endpoint Functions: Independent, typed services.
  3. Data Hooks: TanStack Query (React Query) wrapper hooks.

Step 1: Centralized Client (src/lib/api-client.ts)

import axios from "axios";
import { config } from "@/core/config";
 
export const apiClient = axios.create({
  baseURL: config.apiUrl,
  timeout: 10000,
  headers: {
    "Content-Type": "application/json",
  },
});
 
// Attach bearer token automatically on request
apiClient.interceptors.request.use((req) => {
  const token = localStorage.getItem("token");
  if (token && req.headers) {
    req.headers.Authorization = `Bearer ${token}`;
  }
  return req;
});

Step 2: Typed Endpoint Definition (src/features/project-board/api/getProjects.ts)

import { apiClient } from "@/lib/api-client";
import { Project } from "../types";
 
export interface GetProjectsParams {
  status?: "active" | "archived";
}
 
export const getProjects = async (params?: GetProjectsParams): Promise<Project[]> => {
  const response = await apiClient.get<Project[]>("/projects", { params });
  return response.data;
};

Step 3: React Query Hook (src/features/project-board/hooks/useProjects.ts)

import { useQuery } from "@tanstack/react-query";
import { getProjects, GetProjectsParams } from "../api/getProjects";
 
export const useProjects = (params?: GetProjectsParams) => {
  return useQuery({
    queryKey: ["projects", params],
    queryFn: () => getProjects(params),
    staleTime: 5 * 60 * 1000, // 5 minutes cache lifetime
  });
};

6. State Management Architecture: The 4 Categories of State

To prevent global state bloat, categorize state into one of four buckets:

State TypeDescriptionRecommendation
URL StateSlugs, search filters, modal IDs stored in the query parameter.react-router / Next.js router
Server StateCached API payloads, loading indicators, request statuses.TanStack Query (React Query)
Local UI StateDropdowns, toggles, form entries localized to a component.Standard useState / useReducer
Global UI StateApp-wide states like authentication, themes, and sidebar toggles.Zustand

The Problem with Context for High-Frequency State

React Context is designed for low-frequency updates (e.g., locale, theme). If you store frequently updated state in Context, every component consuming that context will re-render whenever any value inside changes.

The Solution: Zustand for Global UI State

Zustand is a lightweight, selector-based state manager that avoids unnecessary re-renders.

Example: Setting up a global store (src/store/uiStore.ts)

import { create } from "zustand";
 
interface UIState {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
  setSidebar: (open: boolean) => void;
}
 
export const useUIStore = create<UIState>((set) => ({
  sidebarOpen: true,
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setSidebar: (open) => set({ sidebarOpen: open }),
}));

Selector-Based Component Consumption:

import { useUIStore } from "@/store/uiStore";
 
export const Sidebar = () => {
  // Only re-renders if 'sidebarOpen' changes
  const isOpen = useUIStore((state) => state.sidebarOpen);
  
  return <div className={`w-64 bg-card-bg ${isOpen ? "block" : "hidden"}`}>Sidebar</div>;
};

7. TypeScript Conventions: Boundaries and Types

The Problem: Duplicate Interfaces & Type Sprawl

In large applications, developers often declare similar interfaces in multiple places: interface User in features/auth, interface UserProfile in features/billing, etc. When fields change, typescript compilations break in non-obvious ways.

The Solution: Clean Separation and Bound Typing

  1. Feature Types: Types that belong to a single domain reside inside features/some-feature/types/index.ts.
  2. Shared Types: Shared, cross-cutting models (e.g., Paginated responses) live in src/types/index.ts.
  3. Bound Request/Response Types: Place request payloads and API responses directly in the endpoint service files.

API Boundary Typing Example:

// src/features/project-board/api/createProject.ts
import { apiClient } from "@/lib/api-client";
import { Project } from "../types";
 
// Typed inputs
export interface CreateProjectPayload {
  name: string;
  description?: string;
  deadline: string;
}
 
// Request bound directly to typed payload and return model
export const createProject = async (payload: CreateProjectPayload): Promise<Project> => {
  const response = await apiClient.post<Project>("/projects", payload);
  return response.data;
};

8. Code Splitting Strategy: Keeping Bundles Slim

The Problem: Massive Initial JS Bundles

If your React build produces a single index.js larger than 500kb, users will experience blank loading states, especially on mobile connections.

The Solution: Route-Level Lazy Loading as a Standard

Always load primary pages asynchronously. Only load heavy features when the user navigates to their specific path.

Example: Routing Setup with React.lazy

// src/routes/AppRoutes.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { LoadingSpinner } from "@/components/LoadingSpinner";
 
// Lazy-loaded pages
const DashboardPage = lazy(() => import("@/pages/Dashboard"));
const SettingsPage = lazy(() => import("@/pages/Settings"));
const BillingPage = lazy(() => import("@/pages/Billing"));
 
export const AppRoutes = () => {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<DashboardPage />} />
          <Route path="/settings" element={<SettingsPage />} />
          <Route path="/billing" element={<BillingPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
};

9. Error Handling Patterns: Defensive Architecture

The Problem: White Screen of Death

A single runtime error in a small widget should not crash the entire application layout.

The Solution: Nested Error Boundaries

Place Error Boundaries at critical levels of your application tree:

  1. Global Boundary: Catches catastrophic startup failures (wraps root).
  2. Layout/Route Boundary: Handles page-specific failures (wraps page content).
  3. Widget Boundary: Isolates non-essential features (e.g., a dashboard chart widget).

Example: Widget-Level Error Boundary Wrap

// src/components/ErrorBoundary/WidgetErrorBoundary.tsx
import { ErrorBoundary } from "react-error-boundary";
 
function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
  return (
    <div className="p-4 rounded-xl border border-rose-500/20 bg-rose-500/10 text-rose-500 text-xs">
      <p className="font-bold">Failed to load panel</p>
      <pre className="mt-1 text-[10px] opacity-80">{error.message}</pre>
      <button 
        onClick={resetErrorBoundary} 
        className="mt-3 px-3 py-1 bg-rose-500 text-white rounded-md font-semibold hover:bg-rose-600 transition-colors"
      >
        Retry
      </button>
    </div>
  );
}
 
export const WidgetErrorBoundary = ({ children }: { children: React.ReactNode }) => {
  return (
    <ErrorBoundary 
      FallbackComponent={ErrorFallback}
      onError={(err) => {
        // Send to monitoring service (Sentry, LogRocket, etc.)
        console.error("Widget failure logged:", err);
      }}
    >
      {children}
    </ErrorBoundary>
  );
};

10. Testing Strategy: High Value over High Coverage

The Problem: Fragile Implementation Tests

Chasing 100% test coverage by testing implementation details (e.g., "does this component have state variable X") leads to fragile tests that break every time you refactor, without actually proving the application works.

The Solution: Pragmatic Testing Segregation

  • Unit Tests: Run on pure helper functions (formatters, data math) and custom hooks.
  • Integration Tests: Test user journeys inside feature modules using React Testing Library and Mock Service Worker (MSW) to mock network responses.
  • E2E Tests (Playwright/Cypress): Reserved only for critical user paths (auth flow, checkout).

Example: Mock Service Worker (MSW) Integration Test

// src/features/project-board/components/Board.test.tsx
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { Board } from "./Board";
 
// Mock API server
const server = setupServer(
  http.get("*/projects", () => {
    return HttpResponse.json([
      { id: "1", name: "Mock Project Alpha", description: "First mock board item" }
    ]);
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
test("loads and renders projects on the board", async () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
 
  render(
    <QueryClientProvider client={queryClient}>
      <Board />
    </QueryClientProvider>
  );
 
  // Check initial loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
 
  // Wait for MSW mock response to populate list
  const projectTitle = await screen.findByText("Mock Project Alpha");
  expect(projectTitle).toBeInTheDocument();
});

11. Performance Patterns: Memoization Discipline

The Problem: Blindly Memoizing Everything

Wrapping every component in React.memo and every function in useCallback adds performance overhead. Comparing dependency arrays in JavaScript is not free.

The Solution: Strategic Memoization

Only use useMemo and useCallback in two scenarios:

  1. Expensive Computations: Heavy calculations (e.g., sorting 10,000 items, parsing large charts).
  2. Reference Equality: When passing objects, arrays, or functions as dependencies to custom hooks or child components that are optimized with React.memo.

Example: Correct use of useMemo and useCallback

import React, { useMemo, useCallback } from "react";
 
export const SearchList = ({ items, onItemSelect }: { items: string[]; onItemSelect: (item: string) => void }) => {
  
  // Scenario 1: Preventing expensive calculation from running on every minor render
  const filteredList = useMemo(() => {
    return items.filter(item => item.toLowerCase().includes("filter-match"));
  }, [items]);
 
  // Scenario 2: Preserving reference equality to prevent child from re-rendering
  const handleSelect = useCallback((name: string) => {
    onItemSelect(name);
  }, [onItemSelect]);
 
  return (
    <div>
      {filteredList.map(item => (
        <ListItem key={item} name={item} onClick={handleSelect} />
      ))}
    </div>
  );
};
 
// Optimized child component
const ListItem = React.memo(({ name, onClick }: { name: string; onClick: (name: string) => void }) => {
  return <div onClick={() => onClick(name)}>{name}</div>;
});
ListItem.displayName = "ListItem";

12. Authentication & Protected Routes

The Problem: Flashing Content and Expired Sessions

Handling tokens and checking session validity incorrectly causes pages to briefly flash authenticated content before redirecting, or log users out abruptly.

The Solution: Route Guards with Session Context

Use a React context provider combined with router guards. Use Axios interceptors to transparently handle token refreshes in the background.

Example: Protected Router Guard (src/routes/ProtectedGuard.tsx)

import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/features/auth/hooks/useAuth";
import { LoadingSpinner } from "@/components/LoadingSpinner";
 
export const ProtectedGuard = () => {
  const { isAuthenticated, isSessionLoading } = useAuth();
  const location = useLocation();
 
  if (isSessionLoading) {
    return <LoadingSpinner />;
  }
 
  // Redirect to login but save the attempted destination url
  if (!isAuthenticated) {
    return <Navigate to={`/login?auth=true&next=${encodeURIComponent(location.pathname)}`} replace />;
  }
 
  // Render child routes
  return <Outlet />;
};

13. Environment Config and Feature Flags

The Problem: Run-time Config Sprawl

Accessing process.env.NEXT_PUBLIC_API_URL or import.meta.env.VITE_API_URL directly inside component logic makes environments hard to manage and audit.

The Solution: Strictly-Typed App Config Wrapper

Read all environment variables at startup, validate their existence, and export a clean config module.

Step 1: Config Wrapper (src/core/config.ts)

const getEnvVar = (key: string): string => {
  const value = import.meta.env[key];
  if (value === undefined) {
    throw new Error(`Critical Config Error: Environment variable ${key} is missing.`);
  }
  return value;
};
 
export const config = {
  apiUrl: getEnvVar("VITE_API_URL"),
  environment: getEnvVar("VITE_APP_ENV") as "development" | "staging" | "production",
  analyticsId: import.meta.env.VITE_ANALYTICS_ID || "",
} as const;

Step 2: Feature Flag Guard Component (src/components/FeatureFlag/FeatureFlag.tsx)

import React, { ReactNode } from "react";
import { useAuth } from "@/features/auth/hooks/useAuth";
 
interface FeatureFlagProps {
  flag: string;
  fallback?: ReactNode;
  children: ReactNode;
}
 
export const FeatureFlag = ({ flag, fallback = null, children }: FeatureFlagProps) => {
  const { user } = useAuth();
  
  // Real-world implementation checks user traits or configuration states
  const enabledFlags = user?.features || [];
  const isEnabled = enabledFlags.includes(flag);
 
  return isEnabled ? <>{children}</> : <>{fallback}</>;
};

14. Closing: Core Architectural Axioms

When architecting massive frontend platforms, let these two core guidelines guide your decisions:

  1. Consistency over Cleverness: It is far better to have a slightly repetitive, standard folder structure that everyone understands than a clever, automated layout that only its creator knows how to modify.
  2. Optimize for the Onboard: Every structural decision you make should answer one question: "Will a new engineer joining the team next week be able to find where a bug is and build a new feature without asking for help?"

Share this Resource

Help other developers level up by sharing this study guide.

⚡ Weekly newsletter

Crack Your Next Frontend Interview.

Join senior engineers who receive practical, deep-dive frontend challenges, detailed concepts, and blueprints directly in their inbox.

  • Senior level React, JS, and CSS interview blueprints
  • System Design & performance optimization deep-dives
  • 100% free, zero spam, unsubscribe with one click

Join the Study Track

We value your privacy. Unsubscribe at any time.