architecture 12 min read · October 28, 2025

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

  1. Presentational vs container separation simplifies testing and reuse
  2. Feature-based structure keeps related code together as codebases grow
  3. Server state libraries handle async state better than Redux ever did
  4. State lives as close as possible to where it’s used
  5. 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.