Building Scalable Frontend Architecture
Architectural patterns that keep a frontend codebase maintainable as it grows from a handful of components to hundreds.
Every frontend codebase starts clean. The problems show up six months in, once there are forty routes, a dozen contributors, and a utils folder nobody wants to open. Scalable architecture isn’t about picking the “correct” framework pattern on day one — it’s about making a handful of structural decisions early enough that the codebase degrades gracefully instead of collapsing under its own weight. I’ve rebuilt or rescued enough Angular and React/Next.js apps to have a short list of what actually matters.
Feature-Based Structure Beats Type-Based Structure
The default instinct, especially in smaller apps, is to organize by type: a components/ folder, a services/ folder, a hooks/ folder, a models/ folder. It feels tidy. It also means that implementing or understanding a single feature — say, “order checkout” — requires jumping across five unrelated top-level directories.
Type-based structure:
src/
components/
OrderSummary.tsx
PaymentForm.tsx
ProductCard.tsx
hooks/
useOrder.ts
useProduct.ts
services/
orderApi.ts
productApi.ts
Feature-based structure:
src/
features/
checkout/
components/
OrderSummary.tsx
PaymentForm.tsx
hooks/
useOrder.ts
api/
orderApi.ts
types.ts
catalog/
components/
ProductCard.tsx
hooks/
useProduct.ts
api/
productApi.ts
With features as the top-level unit, a new contributor working on checkout touches one directory. Deleting a feature is an actual rm -rf, not an archaeology project. This same idea applies directly in Angular, where it maps onto feature modules (or, with standalone components, feature-scoped directories with their own routes, components, and services) rather than the old pattern of global shared and core modules swelling indefinitely.
The one thing that should stay type-based is anything genuinely cross-cutting: a design system, auth utilities, a global API client. Put those in shared/ or lib/, and hold the line on what’s allowed in there — “shared” folders have a way of becoming a dumping ground if nobody owns the decision of what belongs.
Layering: UI, Domain, and Data
Independent of folder structure, the more important discipline is keeping three concerns separate within a feature:
- UI layer — components concerned with rendering and user interaction. No direct
fetchcalls, no business rules. - Domain layer — the actual business logic: validation rules, calculations, state transitions.
- Data layer — API calls, caching, serialization/deserialization between wire format and your internal types.
A common smell is a React component that fetches data, transforms it, applies business rules, and renders — all in one useEffect. It works until you need that same logic in a second place, or need to unit test the business rule without mounting a component and mocking fetch.
// data layer — knows about HTTP, nothing about UI
export async function fetchOrder(id: string): Promise<OrderDto> {
const res = await apiClient.get<OrderDto>(`/orders/${id}`);
return res.data;
}
// domain layer — knows about business rules, nothing about HTTP or React
export function calculateOrderTotal(order: Order): number {
return order.lineItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
- order.discount;
}
// UI layer — composes the above, renders
function OrderSummary({ orderId }: { orderId: string }) {
const { data: order } = useOrder(orderId); // wraps fetchOrder + caching
if (!order) return <Skeleton />;
return <p>Total: {formatCurrency(calculateOrderTotal(order))}</p>;
}
This separation is what makes the domain logic testable without a DOM, and what makes the data layer swappable (REST today, GraphQL tomorrow) without touching business rules. For more on the data-layer half of this specifically, see REST API Integration Best Practices.
A Consistent API Client Layer
Scattering fetch or axios calls across components is one of the fastest ways to make a codebase unmaintainable. Every call site ends up with slightly different error handling, slightly different base URLs, slightly different auth header logic. Centralize it:
class ApiClient {
constructor(private baseUrl: string) {}
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(path, this.baseUrl);
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const res = await fetch(url, { headers: this.authHeaders() });
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}
private authHeaders(): HeadersInit {
const token = getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
}
export const apiClient = new ApiClient(import.meta.env.VITE_API_URL);
Every feature’s data layer goes through this one object. When auth, retry behavior, or the API host changes, it changes in one place.
Composition and State Management Over Prop-Drilling
Prop-drilling — passing a prop through four layers of components that don’t use it themselves, just to get it to the fifth — is a structural symptom, not just an annoyance. It means your component tree’s shape doesn’t match your data’s shape.
Two independent fixes, often needed together:
Composition — restructure so intermediate components don’t need to know about the prop at all:
// Before: Layout receives `user` only to hand it to Header
<Layout user={user}>
<Page />
</Layout>
// After: Header is composed directly where it's used
<Layout header={<Header user={user} />}>
<Page />
</Layout>
State management — for state genuinely needed across distant parts of the tree, use context (React), signals or a store service (Angular), or a dedicated library (NgRx, Zustand, Redux Toolkit). The mistake is reaching for global state management for things that are actually local — a form’s draft value doesn’t need to live in Redux. Reserve global state for things that are genuinely cross-cutting: the authenticated user, feature flags, a shopping cart.
Shared Component Libraries and Design Tokens
Once an app has more than one team or more than a few dozen components, visual and behavioral inconsistency creeps in fast — three slightly different button components, two different date formatting conventions. A shared component library (internal, versioned or not) solves the behavioral half. Design tokens solve the visual half:
// tokens.ts
export const tokens = {
color: {
primary: '#2563eb',
danger: '#dc2626',
textMuted: '#6b7280',
},
spacing: { sm: '0.5rem', md: '1rem', lg: '1.5rem' },
radius: { sm: '4px', md: '8px' },
};
Consumed via CSS variables or a Tailwind theme config, tokens mean a rebrand or a dark-mode pass touches one file instead of a thousand hardcoded hex values. This matters more than it sounds like it should — I’ve seen “simple” color changes turn into multi-day projects purely because colors were inlined everywhere.
When to Introduce a Monorepo
Monorepos (via Nx, Turborepo, or plain npm/pnpm workspaces) solve a specific problem: sharing code — a design system, a set of types, a utility library — across multiple applications without publishing and versioning an internal npm package for every change. They are not a default you should reach for on day one.
Introduce one when you actually have multiple deployable apps (a marketing site and a dashboard app, say, or a Next.js storefront alongside an Angular admin panel) that need to share real code. Don’t introduce one just because a single app’s codebase feels big — that’s a folder structure problem, not a multi-repo problem, and the tooling overhead (shared build config, dependency graph management, CI caching) is real cost that isn’t worth paying for a single application.
Common Mistakes
- Organizing by type long after the app has outgrown it, making every feature change a multi-folder hunt.
- Letting UI components own business logic, making it untestable without mounting the component.
- Scattering API calls instead of centralizing them, so error handling and auth drift inconsistently across the app.
- Reaching for global state management to solve local, component-scoped problems.
- Adopting a monorepo for tooling reasons before there’s a second app that actually needs shared code.
Best Practices
- Default to feature-based folders; keep
shared/deliberately small and reviewed. - Keep UI, domain, and data layers separable even within a single feature folder.
- Route all network calls through one API client abstraction.
- Reach for composition before reaching for global state.
- Use TypeScript across all three layers — the type boundary between your data layer’s DTOs and your domain’s internal models catches a surprising number of bugs before they ship.
None of this is exotic. It’s the same handful of decisions, applied consistently, that separates a codebase that’s pleasant to work in at 50,000 lines from one that isn’t. For the runtime side of scaling an app — once the structure is right — Frontend Performance Optimization Techniques covers what to do about load time and responsiveness.
Related Reading
Written by
Tayyab Roy
Senior Frontend / Full-Stack Engineer
Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.
Related Articles
REST API Integration Best Practices
Patterns for integrating REST APIs cleanly on the frontend — error handling, caching, and typing responses.
Shopify Theme Development: Architecture and Best Practices
How to structure a maintainable Shopify theme — Online Store 2.0 sections, JS architecture, and asset organization.
TypeScript Best Practices for Large Applications
TypeScript patterns that actually hold up as a codebase grows — strict mode, discriminated unions, and avoiding type soup.