Frontend Architecture Patterns
How to build scalable, maintainable web apps in a fast-moving ecosystem

In most real projects I join, the codebase looks fine at first glance. It runs, tests pass, and features ship. Yet a few months later, every new requirement feels like untangling headphone cables. State is scattered, data fetching lives in three different places, and adding a button means touching five files. This is not a framework problem. It is an architecture problem. Over the last decade, frontend development shifted from simple pages to complex, stateful applications. The patterns we pick at the start determine whether the project stays pleasant or becomes a drag on productivity.
You will not find a single perfect pattern here. Instead, I will share patterns that have consistently helped me build systems that are easier to reason about, test, and change. We will look at where each fits, what they look like in practice, and where they introduce complexity that might not be worth it. To keep things concrete, I will use TypeScript examples and lightweight tooling, because that is where I have spent most of my time recently. You can adapt these ideas to React, Vue, Svelte, or even vanilla web components.
Context: Where frontend architecture fits today
In 2025, most teams build interactive applications rather than static sites. SPA (Single Page Application) remains common, but many teams are mixing server rendering and client hydration via tools like Next.js or Remix for SEO and performance. Micro frontends have matured enough that they are a pragmatic choice for large organizations, while smaller teams prefer a monorepo with clear module boundaries.
The people who care most about architecture are those who maintain apps for more than a few months. If you own a product used by thousands with multiple contributors, structure matters. If you are building a small tool or prototype, you can start simpler and evolve. Compared to other approaches, architecture patterns are not frameworks. They are constraints that help you organize code and data flow. Alternatives include ad-hoc patterns (scattered useEffect calls), global “god stores”, or server-driven UIs where the client is thin. Each has a place, but they often trade short-term speed for long-term complexity.
The core building blocks
I like to think of a frontend system as four layers:
- Presentation: UI components, layout, accessibility, and user events.
- Domain logic: Business rules, validation, and orchestration of actions.
- Data access: API calls, caching, and persistence.
- State: The single source of truth across the session.
In mature codebases, these layers are not strictly enforced, but their boundaries are clear. The goal is to prevent the presentation layer from leaking business logic or network details. That is what patterns like MVC, MVVM, Flux, and Clean/Hexagonal Architecture try to do in different ways.
MVC and MVVM: Organizing the view
MVC (Model-View-Controller) and MVVM (Model-View-ViewModel) have a long history in frontend. In modern UI libraries, “views” are components, and “controllers” often end up as hooks or event handlers. MVVM shines when the view needs a transformed version of data without owning the transformation logic.
In practice, an MVVM-style adapter keeps components focused on rendering and user events, while the ViewModel prepares data and handles actions. This is useful when you have complex derived state or need to decouple the UI from server shapes.
Example: A product list that shows items, applies filters, and handles loading states.
// types.ts
export type Product = {
id: string;
name: string;
price: number;
category: string;
};
export type ProductListState = {
items: Product[];
filters: { search: string; category: string };
loading: boolean;
error: string | null;
};
// view-model.ts
export class ProductListViewModel {
private state: ProductListState = {
items: [],
filters: { search: "", category: "" },
loading: false,
error: null,
};
private subscribers = new Set<() => void>();
constructor(private api: { fetchProducts: () => Promise<Product[]> }) {}
subscribe(fn: () => void) {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
private notify() {
this.subscribers.forEach((fn) => fn());
}
get filteredItems() {
const { search, category } = this.state.filters;
const q = search.toLowerCase().trim();
return this.state.items.filter((p) => {
const matchesSearch = !q || p.name.toLowerCase().includes(q);
const matchesCategory = !category || p.category === category;
return matchesSearch && matchesCategory;
});
}
get loading() {
return this.state.loading;
}
get error() {
return this.state.error;
}
async load() {
this.state.loading = true;
this.state.error = null;
this.notify();
try {
const items = await this.api.fetchProducts();
this.state.items = items;
} catch (e) {
this.state.error = (e as Error).message;
} finally {
this.state.loading = false;
this.notify();
}
}
setSearch(value: string) {
this.state.filters.search = value;
this.notify();
}
setCategory(value: string) {
this.state.filters.category = value;
this.notify();
}
}
// ui.tsx
import React from "react";
import { ProductListViewModel } from "./view-model";
export function ProductList({ vm }: { vm: ProductListViewModel }) {
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
React.useEffect(() => vm.subscribe(forceUpdate), [vm]);
return (
<div>
<input
aria-label="Search"
placeholder="Search"
onChange={(e) => vm.setSearch(e.target.value)}
/>
<select aria-label="Category" onChange={(e) => vm.setCategory(e.target.value)}>
<option value="">All</option>
<option value="Books">Books</option>
<option value="Electronics">Electronics</option>
</select>
{vm.loading && <p>Loading...</p>}
{vm.error && <p role="alert">Error: {vm.error}</p>}
<ul>
{vm.filteredItems.map((p) => (
<li key={p.id}>
{p.name} - ${p.price.toFixed(2)}
</li>
))}
</ul>
</div>
);
}
// app.tsx (wiring)
import { ProductList } from "./ui";
import { ProductListViewModel } from "./view-model";
const vm = new ProductListViewModel({
fetchProducts: async () => {
const res = await fetch("/api/products");
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
},
});
export default function App() {
React.useEffect(() => {
vm.load();
}, []);
return <ProductList vm={vm} />;
}
This is intentionally simple, but the separation is clear. The UI does not know how filtering is computed, and the ViewModel does not depend on React. That makes it easier to test and reuse. Many teams use libraries to handle state and data fetching, but the MVVM boundary still helps keep components thin.
Flux and Redux-like patterns: Predictable data flow
Flux popularized a unidirectional data flow: actions go in, they update a central store, and the UI renders from that store. It works well for complex apps where multiple features interact through shared state. The tradeoff is ceremony: you define actions, reducers, and selectors, and you often need middleware for async work.
In modern codebases, teams often use Redux Toolkit or Zustand for simpler stores. The key idea remains: state is centralized, updates are explicit, and derived state is computed via selectors. This pattern helps avoid “prop drilling” and hidden state mutations.
Example: A shopping cart implemented with a Redux-style store.
// cart-store.ts
type CartItem = { id: string; name: string; price: number; qty: number };
type CartState = {
items: CartItem[];
};
type Action =
| { type: "ADD_ITEM"; payload: { id: string; name: string; price: number } }
| { type: "REMOVE_ITEM"; payload: { id: string } }
| { type: "UPDATE_QTY"; payload: { id: string; qty: number } };
function reducer(state: CartState, action: Action): CartState {
switch (action.type) {
case "ADD_ITEM": {
const { id, name, price } = action.payload;
const exists = state.items.find((i) => i.id === id);
if (exists) {
return {
items: state.items.map((i) =>
i.id === id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { items: [...state.items, { id, name, price, qty: 1 }] };
}
case "REMOVE_ITEM":
return { items: state.items.filter((i) => i.id !== action.payload.id) };
case "UPDATE_QTY":
return {
items: state.items.map((i) =>
i.id === action.payload.id
? { ...i, qty: Math.max(1, action.payload.qty) }
: i
),
};
default:
return state;
}
}
function createStore(initialState: CartState) {
let state = initialState;
const listeners = new Set<(s: CartState) => void>();
return {
getState: () => state,
dispatch: (action: Action) => {
state = reducer(state, action);
listeners.forEach((l) => l(state));
},
subscribe: (listener: (s: CartState) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
// Derived selector example
export function cartTotal(state: CartState) {
return state.items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
This pattern shines when multiple parts of the app need to react to the same state changes. The downside is overkill for small apps. A common mistake is putting server data directly in the store without caching strategies. A better approach is to treat the store as the client’s “session state” and keep server data in a cache layer (more on this below).
Clean/Hexagonal architecture in the frontend
You might have heard of “Clean Architecture” from the backend world. It’s equally valuable in the frontend when you want to isolate business rules from frameworks. The idea is to keep domain logic independent and have adapters that translate between your domain and the outside world (APIs, UI libraries, third-party services).
In frontend terms:
- Domain: Pure functions and models, no React or fetch calls.
- Adapters: Data fetchers, UI components, state hooks.
- Use cases: Orchestrate actions and rules.
This approach makes your app easier to test and refactor, especially when you need to swap data sources or UI frameworks.
Example: A domain service and adapters for user registration.
// domain/types.ts
export type User = { id: string; email: string };
export type RegistrationResult =
| { ok: true; user: User }
| { ok: false; errors: string[] };
// domain/user-service.ts
export class UserService {
constructor(private repo: UserRepository) {}
async register(email: string, password: string): Promise<RegistrationResult> {
const errors: string[] = [];
if (!email.includes("@")) errors.push("Invalid email");
if (password.length < 8) errors.push("Password too short");
if (errors.length > 0) return { ok: false, errors };
const exists = await this.repo.emailExists(email);
if (exists) return { ok: false, errors: ["Email already used"] };
const user = await this.repo.createUser(email, password);
return { ok: true, user };
}
}
export interface UserRepository {
emailExists(email: string): Promise<boolean>;
createUser(email: string, password: string): Promise<User>;
}
// adapters/api-repo.ts
export class HttpUserRepository implements UserRepository {
async emailExists(email: string): Promise<boolean> {
const res = await fetch(`/api/users/exists?email=${encodeURIComponent(email)}`);
const data = await res.json();
return data.exists as boolean;
}
async createUser(email: string, password: string): Promise<User> {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Registration failed");
return res.json();
}
}
// adapters/ui.tsx
import React, { useState } from "react";
import { UserService } from "../domain/user-service";
import { HttpUserRepository } from "./api-repo";
const service = new UserService(new HttpUserRepository());
export function RegistrationForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [result, setResult] = useState<{ ok: boolean; user?: any; errors?: string[] } | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
const res = await service.register(email, password);
setResult(res);
}
return (
<form onSubmit={submit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Register</button>
{result && !result.ok && (
<ul>{result.errors?.map((e, i) => <li key={i}>{e}</li>)}</ul>
)}
{result?.ok && <p>Welcome, {result.user.id}</p>}
</form>
);
}
With this structure, you can test the UserService without any UI or network stack. If you decide to move registration to a server action or Web Worker, only the adapter changes. This is where Clean Architecture pays off: the core business rules remain stable while the implementation details evolve.
State management and caching patterns
Most complexity in frontend apps comes from managing server data and client state. It is tempting to shove both into a single store, but that often leads to cache invalidation nightmares. A practical split is:
- Cache layer: Data fetched from the server, with ttl/invalidate logic.
- Client store: UI state, session preferences, optimistic updates.
Libraries like TanStack Query (React Query), SWR, or Apollo handle caching and synchronization well. They integrate with Redux or Zustand when you need global client state. The key idea is to keep server data outside the core domain models and bring it in through adapters.
A common mistake is doing optimistic updates without rollback. If a request fails, the UI must revert the change and notify the user. It is also wise to avoid sharing mutable objects between cache and store. Treat API responses as immutable snapshots.
Example: A simplified query cache wrapper.
// cache.ts
type QueryKey = string;
export class QueryCache {
private cache = new Map<QueryKey, { data: unknown; timestamp: number }>();
private ttlMs = 5 * 60 * 1000; // 5 minutes
async fetch<T>(key: QueryKey, fn: () => Promise<T>): Promise<T> {
const now = Date.now();
const entry = this.cache.get(key);
if (entry && now - entry.timestamp < this.ttlMs) {
return entry.data as T;
}
const data = await fn();
this.cache.set(key, { data, timestamp: now });
return data;
}
invalidate(key: QueryKey) {
this.cache.delete(key);
}
}
// usage.ts
import { QueryCache } from "./cache";
const cache = new QueryCache();
async function loadProducts() {
return cache.fetch("products:list", async () => {
const res = await fetch("/api/products");
if (!res.ok) throw new Error("Failed to load");
return res.json();
});
}
This is very minimal, but it demonstrates the separation. In production, you would add background refetch, pagination, and error boundaries. Teams that mix server data with UI state often end up with subtle bugs. Isolating caching prevents that.
Micro frontends and module federation
When multiple teams ship parts of the same app, micro frontends help scale development. They allow independent deployments and technology diversity. The cost is integration complexity and performance overhead. Module Federation in Webpack or Vite allows you to federate modules at runtime. This works well when you have a shell application and feature teams that own their domains.
Example: A shell that loads a remote module.
// shell/src/bootstrap.js
import { mount } from "marketing/App";
document.addEventListener("DOMContentLoaded", () => {
mount(document.getElementById("marketing-root"));
});
// shell/webpack.config.js (excerpt)
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
marketing: "marketing@http://localhost:8081/remoteEntry.js",
},
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
}),
],
};
Micro frontends are a strong choice when organizational boundaries map to code boundaries. If you are a small team, this pattern might add more complexity than it solves. Instead, a well-structured monorepo with library packages can deliver similar isolation with less overhead.
Performance and rendering strategies
Architecture is not just about code structure; it also shapes performance. Server-side rendering (SSR) and incremental static regeneration (ISR) help reduce time-to-interactive and improve SEO. However, SSR introduces hydration costs and complexity around state serialization. Islands architecture (e.g., Astro) allows interactive components to hydrate independently, which is ideal for content-heavy sites with sparse interactivity.
Choosing between CSR, SSR, or hybrid depends on user experience goals. For dashboards and apps with heavy interactivity, CSR with code-splitting and lazy loading often wins. For marketing pages or documentation, SSR/ISR is better. Measure before optimizing.
Testing and maintainability
Testing follows architecture. With MVVM, you test the ViewModel in isolation. With Flux-like stores, you test reducers and selectors. With Clean Architecture, you test domain services and mock adapters. Cypress or Playwright are great for end-to-end flows, while unit tests cover the logic.
Aim for test stability by avoiding flaky selectors and timing dependencies. Test error states and loading flows, not just happy paths. In CI, run unit tests on every commit and E2E on a schedule or before releases. This balances speed and confidence.
Tradeoffs: When to use which pattern
- MVVM is great when components need prepared data and you want to keep UI thin. It can be overkill for simple pages.
- Flux-like stores are excellent for shared state and predictable updates. For small apps, Zustand or context may be sufficient.
- Clean/Hexagonal architecture is valuable when business rules are core and likely to evolve. It adds layers that might feel heavy for prototypes.
- Micro frontends are powerful for large organizations but introduce performance and coordination overhead.
- SSR/hybrid rendering improves perceived performance but increases server complexity.
No single pattern fits all. I tend to start simple and evolve. For a new feature, I might keep logic close to components and refactor into a service when it grows. That pragmatic approach avoids premature abstraction.
Personal experience
In one project, we migrated from a scattered useEffect-based data fetching approach to a centralized cache with a service layer. The bug rate dropped, and onboarding new developers became easier because the data flow was explicit. We still kept a small Zustand store for UI-specific state. The hardest part was not the code but the discipline to keep API calls inside the data access layer. Developers naturally reach for fetch in components. It took code reviews and a few well-placed lint rules to make the boundary stick.
Another time, we tried micro frontends for a marketing site and a dashboard under one shell. The isolation was great, but the shared dependencies caused version conflicts. We solved it by pinning shared libs and moving to a monorepo, which gave us better control. The lesson was that the organizational structure must match the architecture, or friction will emerge.
Getting started: Setup, tooling, and project structure
A practical starting point is a monorepo with apps and packages. This scales from a prototype to a multi-team setup. Below is a minimal structure that works well for TypeScript + React + Vite projects.
apps/
shell/
src/
pages/
components/
routes.ts
package.json
vite.config.ts
marketing/
src/
pages/
components/
package.json
vite.config.ts
packages/
domain/
src/
user-service.ts
cart-store.ts
package.json
adapters/
src/
api-repo.ts
cache.ts
package.json
ui/
src/
Button.tsx
Layout.tsx
package.json
tooling/
eslint-config-custom/
tsconfig/
You can wire shared packages via npm workspaces or a tool like Turborepo. The key mental model is:
- Apps are deployable units.
- Packages are reusable libraries with strict boundaries.
- Domain packages are pure business logic.
- Adapters depend on domain and external systems (API, UI).
- UI packages contain presentational components.
Here is a minimal Vite config that supports Module Federation if you need it.
// apps/shell/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "shell",
remotes: {
marketing: "http://localhost:8081/assets/remoteEntry.js",
},
shared: ["react", "react-dom"],
}),
],
build: { target: "esnext", modulePreload: false },
});
For testing, use Vitest for unit tests and Playwright for E2E. A simple CI step looks like this:
# .github/workflows/ci.yml (excerpt)
name: CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run test:unit
- run: npm run test:e2e
Add linting and type checks to catch issues early. For large codebases, enable TS project references to speed up compilation.
What makes this approach stand out
- Developer experience: Clear boundaries make it obvious where to add code. New contributors find their way faster.
- Maintainability: Changes stay localized. Business rules are testable without the UI.
- Flexibility: You can swap data sources or UI libraries without rewriting the domain.
- Performance: With a cache layer and selective hydration, you can tune for real-world usage.
A fun fact: the original Flux pattern from Facebook emphasized a single direction of data flow. This constraint reduces bugs caused by circular dependencies. Even if you do not use Flux, the principle of unidirectional flow is worth borrowing.
Free learning resources
- React Query Docs (TanStack Query): https://tanstack.com/query/latest - Excellent guide on data fetching and caching strategies.
- Redux Essentials: https://redux.js.org/tutorials/essentials/part-1-overview-concepts - A practical intro to state management patterns.
- Clean Architecture (Uncle Bob) book: A classic reference that applies well to frontend boundaries.
- Webpack Module Federation: https://webpack.js.org/concepts/module-federation/ - The canonical resource for micro frontend integration.
- Vite Docs: https://vitejs.dev/ - Fast tooling that pairs well with modern frontend architectures.
- Playwright Docs: https://playwright.dev/ - Reliable end-to-end testing for web apps.
These resources complement the patterns here with deeper dives and real-world case studies.
Summary: Who should use these patterns and who might skip them
If you are building a medium-to-large web application, especially with multiple contributors or long-term maintenance goals, these patterns will pay off. MVVM helps keep components focused, Flux-like stores make shared state predictable, and Clean/Hexagonal architecture protects your domain logic from framework churn. Micro frontends suit large organizations with distinct teams and release cadences.
If you are building a small prototype or internal tool, you can start simpler: co-locate logic with components, use a lightweight cache, and add structure only when pain appears. Do not over-engineer early. Architecture is a tool for managing complexity, not a goal in itself.
The takeaway is pragmatic: design for change. Keep business rules independent, separate server data from client state, and choose rendering strategies based on user needs. When the codebase tells you it is getting tangled, reach for one of these patterns and refactor incrementally. Your future self will thank you.




