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.