Chapter 23

Project Architecture and Engineering Best Practices

The most common way large React projects die is not a wrong technology choiceโ€”it is architectural decay. A directory structure that starts clean gradually turns into a dumping ground where everything ends up in components/ because no one established or enforced clearer rules. This chapter covers a production-proven architecture for medium-to-large React applications, along with the reasoning behind each structural decision.

Directory Structure: Feature-Based vs Type-Based

Type-Based Organization

src/
โ”œโ”€โ”€ components/     โ† all components
โ”œโ”€โ”€ hooks/          โ† all custom hooks
โ”œโ”€โ”€ pages/          โ† all pages
โ”œโ”€โ”€ services/       โ† all API calls
โ”œโ”€โ”€ utils/          โ† all utility functions
โ””โ”€โ”€ store/          โ† all state management

This structure is intuitive early on, but it does not scale. As the project grows, modifying a single feature requires touching five directories. There is no way to understand the relationships between files from the directory structure alone.

Feature-Based Organization

src/
โ”œโ”€โ”€ features/
โ”‚   โ”œโ”€โ”€ auth/
โ”‚   โ”‚   โ”œโ”€โ”€ components/       โ† components used only by auth
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ LoginForm.tsx
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ OAuthButton.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ hooks/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ useAuth.ts
โ”‚   โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ authApi.ts
โ”‚   โ”‚   โ”œโ”€โ”€ store/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ authSlice.ts
โ”‚   โ”‚   โ””โ”€โ”€ index.ts          โ† public API of the auth feature
โ”‚   โ”œโ”€โ”€ dashboard/
โ”‚   โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”œโ”€โ”€ hooks/
โ”‚   โ”‚   โ””โ”€โ”€ index.ts
โ”‚   โ””โ”€โ”€ settings/
โ”‚       โ””โ”€โ”€ ...
โ”œโ”€โ”€ shared/                   โ† code shared across features
โ”‚   โ”œโ”€โ”€ components/           โ† general-purpose UI components
โ”‚   โ”‚   โ”œโ”€โ”€ Button/
โ”‚   โ”‚   โ”œโ”€โ”€ Modal/
โ”‚   โ”‚   โ””โ”€โ”€ DataTable/
โ”‚   โ”œโ”€โ”€ hooks/
โ”‚   โ””โ”€โ”€ utils/
โ”œโ”€โ”€ pages/                    โ† thin route pages that compose feature components
โ”‚   โ”œโ”€โ”€ DashboardPage.tsx
โ”‚   โ””โ”€โ”€ SettingsPage.tsx
โ””โ”€โ”€ app/                      โ† application bootstrap
    โ”œโ”€โ”€ router.tsx
    โ”œโ”€โ”€ store.ts
    โ””โ”€โ”€ main.tsx

The core value of feature-based organization: all code related to a feature lives together physically. A new engineer can understand a feature's full implementation by looking in one directory. Deleting a feature means deleting one directory. Each feature exposes its public API via index.ts; other features import only from that public surface, not from internal paths. ESLint rules can enforce this boundary automatically.

The Barrel Export (index.ts) Debate

Barrel files re-export everything a module makes public from a single index.ts. The developer experience benefit is real:

// With barrel
import { Button, Input, Modal } from '@/shared/components';

// Without barrel (internal structure leaks into import paths)
import { Button } from '@/shared/components/Button/Button';
import { Input } from '@/shared/components/Input/Input';
import { Modal } from '@/shared/components/Modal/Modal';

But the trade-offs are worth understanding.

Bundle size. Build tools (Vite, Webpack) may fail to tree-shake barrel files preciselyโ€”importing Button from a barrel could pull in the entire barrel. Modern bundlers in ESM mode have improved significantly here, but the risk persists in CommonJS environments.

Circular dependencies. Barrel files create easy paths for circular dependencies, especially when features import from each other. eslint-plugin-import's import/no-cycle rule detects them but cannot eliminate the structural temptation.

TypeScript language server performance. In very large projects (tens of thousands of files), barrel files slow down TypeScript's language service because resolving a single import requires traversing the entire barrel.

Practical rule: use barrel files in shared/components (stable, genuinely cross-cutting). Do not use barrel files inside feature directories (import directly from the source file). The feature's index.ts exports only what should be visible to other featuresโ€”it is a deliberate API surface, not a convenience re-export of everything.

Component Design Principles

Single Responsibility: One Job per Component

Over-loaded components are the most common structural problem in growing React projects. A component should not simultaneously own: data fetching, data transformation, rendering, and user interaction handling.

// Wrong: one component does too many things
function UserDashboard() {
  const [users, setUsers] = useState<User[]>([]);
  const [filter, setFilter] = useState('');
  useEffect(() => { fetchUsers().then(setUsers); }, []);
  const filtered = users.filter(u => u.name.includes(filter));
  // rendering, event handling, and everything elseโ€”all in here
}

// Correct: separated responsibilities
function UserDashboardPage() {
  return (
    <UserDataProvider>       {/* data fetching */}
      <UserDashboardLayout>  {/* layout */}
        <UserFilterBar />    {/* filter interaction */}
        <UserTable />        {/* data display */}
      </UserDashboardLayout>
    </UserDataProvider>
  );
}

Composition Over Inheritance

React's composition model is designed for building flexible components without the rigidity of inheritance:

interface CardProps {
  header?: React.ReactNode;
  footer?: React.ReactNode;
  children: React.ReactNode;
  className?: string;
}

function Card({ header, footer, children, className }: CardProps) {
  return (
    <div className={`card ${className ?? ''}`}>
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}

// Consumer controls every region independently, no subclassing needed
<Card
  header={<h2>User Profile</h2>}
  footer={<Button>Edit</Button>}
>
  <UserProfile user={user} />
</Card>

Monorepo with Turborepo

When a project expands to multiple applications (admin dashboard, customer-facing app, marketing site) that share a component library, utility functions, and API types, a monorepo is the right organizational move.

npx create-turbo@latest my-monorepo

Typical Turborepo Structure

apps/
โ”œโ”€โ”€ web/              โ† customer-facing Next.js app
โ”œโ”€โ”€ admin/            โ† admin dashboard, Vite + React
โ””โ”€โ”€ docs/             โ† documentation site
packages/
โ”œโ”€โ”€ ui/               โ† shared component library
โ”‚   โ”œโ”€โ”€ src/
โ”‚   โ”‚   โ”œโ”€โ”€ Button/
โ”‚   โ”‚   โ””โ”€โ”€ index.ts
โ”‚   โ””โ”€โ”€ package.json
โ”œโ”€โ”€ shared/           โ† shared utilities, types, constants
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ”œโ”€โ”€ types/
โ”‚       โ””โ”€โ”€ utils/
โ””โ”€โ”€ tsconfig/         โ† shared TypeScript configurations
    โ”œโ”€โ”€ base.json
    โ”œโ”€โ”€ react.json
    โ””โ”€โ”€ nextjs.json

turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],  // build dependencies first
      "inputs": ["src/**", "package.json"],
      "outputs": [".next/**", "dist/**"]
    },
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "test/**"]
    },
    "lint": {
      "inputs": ["src/**", ".eslintrc.*"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Turborepo's core value is incremental build caching: only packages with changed files are rebuilt; everything else hits the cache. In a large monorepo this can reduce CI time from 20 minutes to under 2 minutes.

Code Standards Toolchain

ESLint + Prettier Configuration

npm install -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-jsx-a11y prettier eslint-config-prettier
// eslint.config.js (ESLint v9 flat config)
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import prettier from 'eslint-config-prettier';

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    plugins: {
      'react-hooks': reactHooks,
      'jsx-a11y': jsxA11y,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-hooks/exhaustive-deps': 'error',   // upgrade to error, no silent bypasses
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'jsx-a11y/alt-text': 'error',
      // Prevent cross-feature internal imports (requires eslint-plugin-import)
      'import/no-restricted-paths': [
        'error',
        {
          zones: [
            {
              target: './src/features/auth',
              from: './src/features/dashboard',
              message: 'auth feature must not import dashboard feature internals',
            },
          ],
        },
      ],
    },
  },
  prettier  // must be last: disables formatting rules that conflict with Prettier
);

Husky + lint-staged: Pre-Commit Automation

npm install -D husky lint-staged
npx husky init
// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,css,md}": [
      "prettier --write"
    ]
  }
}
# .husky/pre-commit
npx lint-staged

commitlint: Enforcing Commit Message Conventions

npm install -D @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'docs', 'style', 'refactor',
      'test', 'chore', 'perf', 'ci', 'build', 'revert'
    ]],
  },
};
# .husky/commit-msg
npx commitlint --edit $1

Conventional commit messages (feat: add user login) are not just cosmetic. They are the machine-readable input for automated changelog generation and semantic versioning (semantic-release).

Environment Configuration Management

.env                    โ† shared defaults across all environments (commit to git)
.env.local              โ† local overrides (git-ignored)
.env.development        โ† development-specific (commit to git)
.env.production         โ† production config without secrets (commit to git)
.env.production.local   โ† production secrets (git-ignored, injected by CI)

In Vite, only variables prefixed with VITE_ are exposed to the client bundle:

// src/config/env.ts โ€” centralized validation and export
const requiredEnvVars = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  sentryDsn: import.meta.env.VITE_SENTRY_DSN,
  appEnv: import.meta.env.VITE_APP_ENV ?? 'development',
} as const;

// Validate at startup, not deep in a feature at runtime
Object.entries(requiredEnvVars).forEach(([key, value]) => {
  if (!value && import.meta.env.PROD) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
});

export const env = requiredEnvVars;

Never scatter import.meta.env.VITE_* calls across the codebase. Centralize, validate, and export through a single module. Missing environment configuration should fail loudly at startup, not silently at runtime inside a deeply nested feature.

A Realistic Mid-to-Large Application Architecture

A SaaS admin dashboard illustrates how all these pieces fit together:

src/
โ”œโ”€โ”€ app/
โ”‚   โ”œโ”€โ”€ router.tsx          โ† React Router configuration
โ”‚   โ”œโ”€โ”€ store.ts            โ† Redux/Zustand root store
โ”‚   โ”œโ”€โ”€ queryClient.ts      โ† React Query client configuration
โ”‚   โ””โ”€โ”€ main.tsx            โ† entry point, provider assembly
โ”œโ”€โ”€ features/
โ”‚   โ”œโ”€โ”€ auth/
โ”‚   โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ LoginForm.tsx
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ ProtectedRoute.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ hooks/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ useAuth.ts
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ usePermission.ts
โ”‚   โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ authApi.ts        โ† encapsulates auth API calls
โ”‚   โ”‚   โ”œโ”€โ”€ store/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ authSlice.ts
โ”‚   โ”‚   โ””โ”€โ”€ index.ts              โ† export { useAuth, ProtectedRoute }
โ”‚   โ”œโ”€โ”€ users/
โ”‚   โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ UserTable.tsx
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ UserForm.tsx
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ UserFilters.tsx
โ”‚   โ”‚   โ”œโ”€โ”€ hooks/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ useUsers.ts       โ† wraps useQuery + useMutation
โ”‚   โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ usersApi.ts
โ”‚   โ”‚   โ””โ”€โ”€ index.ts
โ”‚   โ””โ”€โ”€ billing/
โ”‚       โ””โ”€โ”€ ...
โ”œโ”€โ”€ shared/
โ”‚   โ”œโ”€โ”€ components/
โ”‚   โ”‚   โ”œโ”€โ”€ Button/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ Button.tsx
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ Button.test.tsx
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ index.ts
โ”‚   โ”‚   โ”œโ”€โ”€ DataTable/
โ”‚   โ”‚   โ””โ”€โ”€ index.ts              โ† shared components barrel
โ”‚   โ”œโ”€โ”€ hooks/
โ”‚   โ”‚   โ”œโ”€โ”€ useDebounce.ts
โ”‚   โ”‚   โ”œโ”€โ”€ useLocalStorage.ts
โ”‚   โ”‚   โ””โ”€โ”€ index.ts
โ”‚   โ””โ”€โ”€ utils/
โ”‚       โ”œโ”€โ”€ format.ts
โ”‚       โ”œโ”€โ”€ validate.ts
โ”‚       โ””โ”€โ”€ index.ts
โ”œโ”€โ”€ pages/                        โ† thin layer, composes feature components
โ”‚   โ”œโ”€โ”€ LoginPage.tsx
โ”‚   โ”œโ”€โ”€ UsersPage.tsx
โ”‚   โ””โ”€โ”€ BillingPage.tsx
โ””โ”€โ”€ types/                        โ† global type declarations
    โ”œโ”€โ”€ api.ts                    โ† API response shapes
    โ””โ”€โ”€ global.d.ts

Several structural decisions are worth making explicit:

Pages are thin. Page components assemble feature components. They contain no business logic. If a page file grows beyond ~50 lines, that is a signal that logic needs to move into a feature component or hook.

Features are self-contained. Each feature holds everything needed to implement its slice of functionality: components, hooks, API calls, and local state. Deleting a feature affects no other feature.

Shared has a strict admission policy. Code enters shared only when it is genuinely needed by two or more features. Premature abstractionโ€”extracting something into shared before it has proven to need reuseโ€”creates coupling without benefit.

Types live close to their users. API response types live in the corresponding services/ file. Component prop types live next to the component. Only truly global declarations go in types/.

Good architecture is not designed once and frozen. It evolves as the business grows. The investment is in establishing clear rules (where does each kind of code live, how do features communicate), enforcing them with tooling, and keeping the team aligned on the rationale. When those three things are in place, a codebase can absorb rapid feature development without degrading into an unmaintainable tangle.

Rate this chapter
4.8  / 5  (6 ratings)

๐Ÿ’ฌ Comments