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.