Modern Frontend Architecture
Scalable patterns and practical strategies for building enterprise-grade web applications.
Frontend applications have grown far beyond simple scripts. Enterprise-grade web apps require thoughtful architecture that scales across teams, features, and time. Here’s what actually works.
The Component Architecture Spectrum
Atomic Design in Practice
Atomic design — atoms, molecules, organisms, templates, pages — provides useful vocabulary, but most teams benefit from simpler distinctions:
- Presentational components: Stateless, focused purely on rendering. Receive data via props.
- Container components: Stateful, handle data fetching and business logic. Connect presentational to state.
// Presentational: only cares about display
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
}
function Button({ label, onClick, variant = 'primary' }: ButtonProps) {
return <button className={`btn btn-${variant}`} onClick={onClick}>{label}</button>;
}
// Container: handles data and logic
function UserButton({ userId }: { userId: string }) {
const { data: user, isLoading } = useUser(userId);
if (isLoading) return <Button label="Loading..." onClick={() => {}} disabled />;
return <Button label={`Hello, ${user.name}`} onClick={() => navigate(`/user/${userId}`)} />;
}
Feature-Based Folder Structure
Feature-based beats type-based as codebases grow:
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── RegisterForm.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── api/
│ │ │ └── authApi.ts
│ │ └── index.ts
│ └── dashboard/
│ └── ...
├── shared/
│ ├── components/
│ ├── hooks/
│ └── utils/
State Management: Choosing the Right Location
The biggest architectural mistake is putting everything in global state.
The State Location Pyramid
Global State
/ \
Server State URL State
\ /
URL Params Search
\ /
Props
Server State: TanStack Query
For data that lives on a server, use a server state library:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: () => fetch('/api/projects').then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch('/api/projects', {
method: 'POST',
body: JSON.stringify(data),
}).then(r => r.json()),
onSuccess: () => queryClient.invalidateQueries(['projects']),
});
}
Client State: Zustand for Simpler Needs
import { create } from 'zustand';
interface UIStore {
sidebarOpen: boolean;
toggleSidebar: () => void;
modal: string | null;
openModal: (id: string) => void;
closeModal: () => void;
}
export const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
modal: null,
openModal: (id) => set({ modal: id }),
closeModal: () => set({ modal: null }),
}));
Rule of thumb: Prefer useState → URL state → server state → global client state. Each step up the pyramid is a stronger coupling decision.
Monorepo Strategies
Turborepo and Nx enable shared code, consistent tooling, and coordinated releases across packages.
Package Boundaries
apps/
web/ # Main React app
admin/ # Admin dashboard
packages/
ui/ # Shared UI components
utils/ # Shared utilities
types/ # Shared TypeScript types
eslint-config/
tsconfig/
Dependency Rules
// turbo.json
{
"pipeline": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"test": {}
}
}
Apps can depend on shared packages, but shared packages can’t depend on other shared packages. This prevents circular dependencies and keeps packages truly reusable.
The API Layer
API Client Pattern
// lib/api.ts
export const api = axios.create({
baseURL: '/api',
timeout: 10000,
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle auth
}
return Promise.reject(error);
}
);
// features/projects/api.ts
export const projectsApi = {
list: () => api.get('/projects'),
get: (id: string) => api.get(`/projects/${id}`),
create: (data: CreateProjectDTO) => api.post('/projects', data),
update: (id: string, data: UpdateProjectDTO) => api.patch(`/projects/${id}`, data),
};
Key Takeaways
- Presentational vs container separation simplifies testing and reuse
- Feature-based structure keeps related code together as codebases grow
- Server state libraries handle async state better than Redux ever did
- State lives as close as possible to where it’s used
- Monorepos force good dependency hygiene with shared tooling
Architecture isn’t about picking the trendiest tools. It’s about making decisions that keep your codebase navigable and your team productive as you scale.