Web Development

Building Scalable Frontend Architecture

Architectural patterns that keep a frontend codebase maintainable as it grows from a handful of components to hundreds.

Tayyab Roy7 min read
Share:

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:

  1. UI layer — components concerned with rendering and user interaction. No direct fetch calls, no business rules.
  2. Domain layer — the actual business logic: validation rules, calculations, state transitions.
  3. 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.

#Architecture#Scalability#Frontend

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.