Scalability as an Architectural Concern
Frontend scalability is not primarily about bundle size or render performance. It is about whether your codebase, your team, and your delivery process can absorb growth without slowing down or breaking down. A codebase that works well for five engineers will often fail badly for twenty-five, not because the code is bad, but because the structures that worked at small scale become obstacles at large scale.
The three dimensions of frontend scalability operate independently and each requires deliberate design:
Code scalability
Can the codebase grow without accumulating coupling? Can engineers navigate it, understand ownership, and make changes without fear of cross-cutting side effects?
Team scalability
Can you add engineers without linearly increasing coordination overhead? Do teams have clear ownership, well-defined interfaces, and the autonomy to ship without waiting on others?
Delivery scalability
Can multiple teams ship independently? Does one team's release depend on another's readiness? Can you roll back one feature without reverting unrelated changes?
Warning signals
- PRs need sign-off from multiple teams
- Releases blocked by unrelated feature readiness
- No clear owner for cross-cutting changes
- Onboarding takes months, not weeks
- Architecture decisions made informally or not at all
What This Guide Covers
- Feature-based architecture: structuring code around business domains rather than technical layers, with explicit public APIs between features.
- Domain-driven frontend design: applying bounded contexts, anti-corruption layers, and ubiquitous language at the frontend layer.
- Monorepos and micro-frontends: the infrastructure decisions that enable or constrain team autonomy, with honest trade-off analysis.
- Governance and ownership: how to make architectural decisions at scale, enforce standards without gatekeeping, and map clear ownership boundaries.
- Release workflows: feature flags, trunk-based development, and deployment patterns that decouple team delivery cycles.
- Testing and performance at scale: test architecture, CI parallelisation, bundle splitting strategies, and performance budgets for large codebases.
Feature-Based Architecture
Layer-based organisation (components/, hooks/, utils/, types/) works well for small codebases but degrades badly as the codebase grows. Changes to a single business feature require touching files scattered across every layer. No single directory represents a coherent unit of business functionality. Ownership is unclear.
Feature-based architecture inverts this: every directory is a business domain, and every file in that directory exists to serve that domain. The result is that changes to a feature are local, ownership is clear, and the codebase structure mirrors the product structure.
Feature Module Structure
src/
features/
catalog/ # product discovery domain
components/
ProductCard.tsx
ProductGrid.tsx
ProductFilters.tsx
hooks/
useProducts.ts
useProductSearch.ts
useProductFilters.ts
types/
product.ts
schemas/
product.ts
__tests__/
useProducts.test.ts
ProductCard.test.tsx
index.ts # public API — only what other features may use
cart/ # purchase intent domain
components/
CartDrawer.tsx
CartItem.tsx
CartSummary.tsx
hooks/
useCart.ts
useCartSync.ts
types/
cart.ts
index.ts
checkout/ # transaction domain
components/
hooks/
types/
index.ts
shared/ # cross-feature primitives (not business logic)
components/
Button.tsx
Modal.tsx
Spinner.tsx
hooks/
useDebounce.ts
useLocalStorage.ts
lib/
api-client.ts
query-client.ts
types/
common.ts
app/ # routing and page composition only
(shop)/
page.tsx
layout.tsx
cart/
page.tsxThe Feature Public API Pattern
Each feature exposes a controlled public surface through its index.ts barrel file. Other features and page-level code import only from that barrel, never from internal paths. This creates an explicit contract: anything not in index.ts is an implementation detail.
// Public API for the catalog feature.
// Other features and pages import from here, never from internal paths.
// Components available for composition in pages
export { ProductCard } from "./components/ProductCard";
export { ProductGrid } from "./components/ProductGrid";
// Hooks available for use in page-level orchestration
export { useProducts } from "./hooks/useProducts";
export { useProductSearch } from "./hooks/useProductSearch";
// Types needed by consumers
export type { Product, ProductFilters, ProductSortOrder } from "./types/product";
// NOT exported: internal hooks, sub-components, implementation details
// ProductFilters component is internal — only ProductGrid uses it
// useProductFilters hook is internal to useProductSearchEnforcing Dependency Rules
Feature modules should form a directed acyclic graph. Features may depend on shared/, but features must not depend on other features unless through a deliberate cross-feature contract. Use ESLint to enforce this automatically.
module.exports = {
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
// Forbid importing from another feature's internals
// Only imports from a feature's index.ts are allowed
group: ["@/features/*/components/*", "@/features/*/hooks/*", "@/features/*/types/*"],
message:
"Import from a feature's public API (index.ts) only. " +
"Direct internal imports create hidden coupling.",
},
],
},
],
},
};Domain-Driven Frontend Design
Domain-driven design (DDD) concepts apply meaningfully at the frontend layer, even without a microservices backend. The core ideas — bounded contexts, ubiquitous language, anti-corruption layers, and domain events — all have direct analogues in how you structure a large React application.
Bounded Contexts in the Frontend
A bounded context is a region of the codebase where a specific domain model applies and has consistent meaning. In frontend terms, it is a feature or group of features that shares a consistent vocabulary, type system, and business logic. The key insight is that the same real-world concept (a “user” or a “product”) can have a completely different representation in different contexts.
// Catalog context: cares about display and discovery
// features/catalog/types/product.ts
export interface CatalogProduct {
id: string;
name: string;
slug: string;
price: number;
thumbnailUrl: string;
rating: number;
inStock: boolean;
category: string;
}
// Cart context: cares about purchase intent and quantities
// features/cart/types/cart-item.ts
export interface CartItem {
productId: string;
productName: string; // denormalised for display without catalog lookup
unitPrice: number;
quantity: number;
variantId?: string;
}
// Order context: cares about fulfilment and history
// features/orders/types/order.ts
export interface OrderLineItem {
sku: string;
description: string;
quantity: number;
unitPricePaid: number; // historical — may differ from current price
discount: number;
}
// Each context owns its model. Duplication of fields is deliberate —
// coupling contexts through a shared Product type creates the wrong dependency.Anti-Corruption Layers
External APIs often use naming conventions, data shapes, or semantics that differ from your domain model. An anti-corruption layer (ACL) translates at the boundary, keeping the external API's vocabulary out of your domain code.
// External API uses snake_case and different field names.
// The ACL translates once at the boundary — domain code never sees the external shape.
import type { ExternalProductDTO } from "@/shared/lib/api-client";
import type { CatalogProduct } from "../types/product";
export function adaptProduct(dto: ExternalProductDTO): CatalogProduct {
return {
id: dto.product_id,
name: dto.product_name,
slug: dto.url_slug,
price: dto.price_cents / 100,
thumbnailUrl: dto.images?.[0]?.url ?? "/placeholder.png",
rating: dto.average_rating ?? 0,
inStock: dto.inventory_status === "IN_STOCK",
category: dto.category_code.toLowerCase().replace(/_/g, "-"),
};
}
export function adaptProductList(dtos: ExternalProductDTO[]): CatalogProduct[] {
return dtos.map(adaptProduct);
}
// Usage in the data-fetching layer — the rest of the feature uses CatalogProduct
async function fetchProducts(filters: ProductFilters): Promise<CatalogProduct[]> {
const response = await apiClient.get<ExternalProductDTO[]>("/v2/products", filters);
return adaptProductList(response.data);
}Domain Events for Cross-Context Communication
Features should not call each other's functions directly. When the cart feature needs to react to something the catalog feature does (or vice versa), they communicate through events. This keeps the dependency direction clean: features emit events into a shared bus; other features subscribe without the emitter knowing who is listening.
// Lightweight domain event bus — no external library needed at this scale.
type Handler<T> = (payload: T) => void;
class DomainEventBus {
private handlers = new Map<string, Handler<unknown>[]>();
emit<T>(event: string, payload: T): void {
(this.handlers.get(event) ?? []).forEach((h) => h(payload as unknown));
}
on<T>(event: string, handler: Handler<T>): () => void {
const existing = this.handlers.get(event) ?? [];
this.handlers.set(event, [...existing, handler as Handler<unknown>]);
return () => {
const updated = (this.handlers.get(event) ?? []).filter((h) => h !== handler);
this.handlers.set(event, updated);
};
}
}
export const domainEvents = new DomainEventBus();
// Domain event type registry — keeps events typed end-to-end
export interface DomainEventMap {
"cart:item-added": { productId: string; quantity: number };
"cart:item-removed": { productId: string };
"cart:cleared": Record<string, never>;
"checkout:completed": { orderId: string; total: number };
"user:signed-in": { userId: string; role: string };
"user:signed-out": Record<string, never>;
}
// Typed emit and on helpers
export function emit<K extends keyof DomainEventMap>(
event: K,
payload: DomainEventMap[K]
): void {
domainEvents.emit(event, payload);
}
export function on<K extends keyof DomainEventMap>(
event: K,
handler: Handler<DomainEventMap[K]>
): () => void {
return domainEvents.on(event, handler);
}Monorepo Strategies
A monorepo houses multiple applications and packages in a single repository with a shared toolchain and dependency graph. For organisations with multiple frontend applications or a shared component library, monorepos solve the coordination problem that polyrepos create: keeping shared code in sync across repositories is expensive and error-prone at scale.
Turborepo vs Nx
| Dimension | Turborepo | Nx |
|---|---|---|
| Setup complexity | Low — minimal config, fast to adopt | Higher — more config, more power |
| Build caching | Remote caching via Vercel or custom backend | Remote caching via Nx Cloud or self-hosted |
| Task orchestration | Pipeline-based, dependency-aware | Task graph, affected commands, project graph |
| Code generation | None built-in | Generators for apps, libs, components |
| Affected detection | Via dependency graph | First-class with nx affected — only test changed code |
| Import boundaries | Via ESLint rules | Built-in module boundary enforcement with tags |
| Best for | Simpler multi-app setups, Vercel-hosted projects | Large orgs with many teams and strict boundaries |
Monorepo Package Structure
my-org/
apps/
web/ # main customer-facing Next.js app
admin/ # internal admin dashboard
marketing/ # marketing/landing pages (separate deployment)
docs/ # internal documentation site
packages/
ui/ # shared component library
src/
components/
tokens/
package.json # @my-org/ui
api-client/ # typed API client shared across apps
src/
package.json # @my-org/api-client
config/ # shared tooling configs
eslint/
tsconfig/
vitest/
package.json # @my-org/config
types/ # shared TypeScript types (API contracts, etc.)
package.json # @my-org/types
utils/ # shared pure utilities (formatting, validation helpers)
package.json # @my-org/utils
turbo.json
package.json # root workspace
pnpm-workspace.yaml # or npm/yarn workspaces{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "__tests__/**", "vitest.config.ts"],
"outputs": ["coverage/**"]
},
"lint": {
"inputs": ["src/**", ".eslintrc.js"]
},
"typecheck": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}{
"name": "@my-org/ui",
"version": "1.4.2",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./tokens": {
"import": "./dist/tokens/index.js",
"types": "./dist/tokens/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@my-org/config": "workspace:*",
"tsup": "^8.0.0"
}
}Affected-Only CI
name: CI
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for Turborepo to detect affected packages
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
# or: yarn install --frozen-lockfile
# or: pnpm install --frozen-lockfile
- name: Build, lint, typecheck, and test (affected only)
run: npx turbo run build lint typecheck test --filter=...[origin/main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}Micro-Frontends
Micro-frontends (MFEs) extend the microservices model to the frontend: each team owns and deploys its slice of the UI independently. Done well, they enable genuine team autonomy at the deployment layer. Done poorly, they add significant complexity without delivering the expected autonomy.
When Micro-Frontends Make Sense
Good fit for MFEs
- Teams need genuinely independent deployment cycles
- Different parts of the app need different tech stacks (legacy migration)
- Regulatory isolation requires separate deployments
- Org structure makes a single release train impractical
- Teams are large enough to absorb the operational overhead
Poor fit for MFEs
- You want autonomy but teams share a release cadence anyway
- The codebase is large but the team is small
- You need deep cross-MFE integration (shared state, shared routing)
- No dedicated platform team to manage the MFE infrastructure
- Features have unclear boundaries (most UI work crosses domains)
Module Federation with Vite
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "host",
remotes: {
// Each remote is a separately deployed app
catalog: "http://localhost:3001/assets/remoteEntry.js",
checkout: "http://localhost:3002/assets/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
"@tanstack/react-query": { singleton: true },
},
}),
],
build: {
target: "esnext",
minify: false,
cssCodeSplit: false,
},
});import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "catalog",
filename: "remoteEntry.js",
exposes: {
// Only expose stable, well-tested surfaces
"./ProductCatalog": "./src/components/ProductCatalog",
"./ProductSearch": "./src/components/ProductSearch",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
"@tanstack/react-query": { singleton: true },
},
}),
],
build: {
target: "esnext",
minify: false,
cssCodeSplit: false,
},
});import React, { Suspense, lazy } from "react";
// Lazy-loaded remote component — type the import explicitly
const ProductCatalog = lazy(
() => import("catalog/ProductCatalog") as Promise<{ default: React.ComponentType<CatalogProps> }>
);
interface CatalogProps {
category?: string;
onProductSelect: (productId: string) => void;
}
export function ShopPage() {
const handleProductSelect = (productId: string) => {
// Cross-MFE navigation via shared router or custom event
window.dispatchEvent(new CustomEvent("navigate", { detail: { path: `/product/${productId}` } }));
};
return (
<Suspense fallback={<CatalogSkeleton />}>
<ProductCatalog onProductSelect={handleProductSelect} />
</Suspense>
);
}Cross-MFE Communication Patterns
| Pattern | Mechanism | Use when | Avoid when |
|---|---|---|---|
| Custom events | window.dispatchEvent | Fire-and-forget notifications (user signed in, cart updated) | You need a synchronous response |
| URL / routing | Shared router, URL params | Navigation between MFE regions | Passing complex state |
| Shared module | Module Federation shared dep | Shared auth state, feature flags, user context | Domain-specific state (creates coupling) |
| Props via shell | Host passes props to remotes | Configuration and callbacks that belong to the host | Deep cross-cutting data |
Shared Component Systems
A shared component system is not just a component library. It is the contract between design and engineering across multiple teams and products. It includes design tokens (the semantic layer), a component API (the behavioural layer), and documentation (the knowledge layer). Each layer must be maintained independently and versioned deliberately.
Design Token Architecture
// Design tokens: the semantic layer — not raw values, but meanings.
// Tools like Style Dictionary or Token Transformer generate these from
// a Figma-exported token JSON. The structure here is consumed directly.
export const tokens = {
color: {
// Semantic aliases — components use these, not raw values
background: {
primary: "var(--color-bg-primary)",
secondary: "var(--color-bg-secondary)",
elevated: "var(--color-bg-elevated)",
inverse: "var(--color-bg-inverse)",
},
text: {
primary: "var(--color-text-primary)",
secondary: "var(--color-text-secondary)",
disabled: "var(--color-text-disabled)",
inverse: "var(--color-text-inverse)",
},
brand: {
primary: "var(--color-brand-primary)",
primaryHover: "var(--color-brand-primary-hover)",
secondary: "var(--color-brand-secondary)",
},
feedback: {
error: "var(--color-error)",
warning: "var(--color-warning)",
success: "var(--color-success)",
info: "var(--color-info)",
},
},
spacing: {
0: "0",
1: "0.25rem",
2: "0.5rem",
3: "0.75rem",
4: "1rem",
6: "1.5rem",
8: "2rem",
12: "3rem",
16: "4rem",
},
radius: {
sm: "4px",
md: "8px",
lg: "12px",
xl: "16px",
full: "9999px",
},
shadow: {
sm: "0 1px 2px rgba(0,0,0,0.05)",
md: "0 4px 6px rgba(0,0,0,0.07)",
lg: "0 10px 15px rgba(0,0,0,0.1)",
},
} as const;
export type DesignTokens = typeof tokens;Component API Design for Shared Libraries
import React from "react";
import styles from "./Button.module.css";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual hierarchy. 'primary' for main CTA, 'secondary' for alternatives, 'ghost' for low emphasis. */
variant?: "primary" | "secondary" | "ghost" | "danger";
/** Size affects padding and font size. Default is 'md'. */
size?: "sm" | "md" | "lg";
/** Renders a loading spinner and disables interaction. */
loading?: boolean;
/** Renders icon on the left of the label. */
leadingIcon?: React.ReactNode;
/** Renders icon on the right of the label. */
trailingIcon?: React.ReactNode;
/** Use 'full' to stretch to container width. */
width?: "auto" | "full";
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = "primary",
size = "md",
loading = false,
leadingIcon,
trailingIcon,
width = "auto",
children,
disabled,
className,
...rest
},
ref
) => {
return (
<button
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
data-variant={variant}
data-size={size}
data-width={width}
className={[styles.button, className].filter(Boolean).join(" ")}
{...rest}
>
{loading ? (
<span className={styles.spinner} aria-hidden="true" />
) : leadingIcon ? (
<span className={styles.icon} aria-hidden="true">{leadingIcon}</span>
) : null}
<span className={styles.label}>{children}</span>
{!loading && trailingIcon && (
<span className={styles.icon} aria-hidden="true">{trailingIcon}</span>
)}
</button>
);
}
);
Button.displayName = "Button";Versioning Strategy
Shared component libraries must follow strict semver and maintain a changelog. Breaking changes to component APIs are expensive: every consuming application must update. Establish these conventions before the first external consumer appears.
| Change type | Version bump | Examples | Migration required |
|---|---|---|---|
| Breaking | Major (1.x.x → 2.0.0) | Removing a prop, renaming a prop, changing a prop type to be incompatible, removing a component, changing default behaviour | All consumers must update |
| Additive | Minor (1.2.x → 1.3.0) | Adding new optional props, adding new components, new exported types | None required |
| Fix | Patch (1.2.0 → 1.2.1) | Accessibility fixes, visual regression fixes, performance improvements | None required |
| Deprecation | Minor | Marking a prop or component as deprecated with a console warning and a migration path | Encouraged before next major |
Frontend Governance
Governance is the system by which an engineering organisation makes and enforces architectural decisions. Without it, decisions happen informally, standards diverge across teams, and technical debt accumulates invisibly. With it, teams can move autonomously within clear boundaries, with a lightweight process for changing those boundaries when needed.
The RFC Process
A Request for Comment (RFC) process formalises how significant architectural changes are proposed and decided. It creates a record of decisions, their rationale, and the alternatives considered. The process does not need to be heavyweight to be effective.
# RFC 0023: Adopt Zod as the Standard Validation Library
## Status
Accepted — 2026-03-14
## Author
Mahsa Mohajer (@mahsamohajer)
## Summary
Adopt Zod as the single standard for runtime validation across all applications
in the monorepo. Replace the current mix of yup, custom validators, and unvalidated
API responses.
## Problem
Currently we have three different validation libraries in use (yup in checkout,
joi in admin, hand-rolled validators in web). This creates inconsistent error
message formats, duplicated schema logic for the same domain types, and a
maintenance burden when shared types change.
## Proposal
1. Add @my-org/schemas package to the monorepo with shared Zod schemas for all
API contracts.
2. Deprecate yup and joi in consuming apps. Provide migration guides.
3. Enforce: all API boundary validation must use Zod schemas from @my-org/schemas
or app-local schemas. No other validation libraries in new code.
## Alternatives Considered
- Valibot: smaller bundle, similar API. Rejected: smaller ecosystem, less TypeScript
integration maturity at time of decision.
- Type assertions only: rejected, defeats the purpose of runtime validation.
## Migration Plan
Phase 1 (April): Create @my-org/schemas. All new code uses Zod.
Phase 2 (Q3): Migrate existing validation in checkout and admin.
Phase 3 (Q4): Remove yup and joi from the dependency graph.
## Decision Record
Accepted with the condition that a Zod-to-yup migration guide is published
before Phase 2 begins.Architecture Review Board
An Architecture Review Board (ARB) is a lightweight body responsible for reviewing significant proposals, maintaining the architectural vision, and resolving cross-team technical disagreements. Effectiveness depends on keeping it small, with rotating membership that includes both senior engineers and team leads, and a turnaround time of one to two weeks for reviews.
Requires ARB review
- Adopting a new shared library or framework
- Introducing a new architectural pattern to the codebase
- Adding a new package to the shared monorepo
- Changing shared API contracts or design tokens
- Changes to the CI/CD pipeline that affect all teams
- Cross-team ownership changes
Team-level decision
- Internal feature architecture choices
- Adding app-level (non-shared) dependencies
- Test strategy within a team's owned code
- Performance optimisations in owned components
- Refactoring within a bounded feature module
- Tooling choices that do not affect other teams
Tech Debt Tracking
// Track tech debt as typed, queryable records rather than TODO comments.
// Run a script to extract and report on outstanding debt by owner and severity.
export const techDebtRegistry = [
{
id: "TD-001",
title: "Migrate checkout form to Zod validation",
severity: "high" as const,
owner: "checkout-team",
effort: "medium" as const,
addedDate: "2026-01-15",
context:
"Currently uses yup schemas. Will be blocked from the next major design system update " +
"until migrated. Part of RFC-0023 migration plan.",
linkedRfc: "RFC-0023",
},
{
id: "TD-002",
title: "Remove direct react-query imports in feature components",
severity: "medium" as const,
owner: "catalog-team",
effort: "low" as const,
addedDate: "2026-02-20",
context:
"About 12 components import useQuery directly. They should use the domain hooks " +
"which provide the correct cache key structure. Creates inconsistent caching behaviour.",
linkedRfc: null,
},
] satisfies TechDebtItem[];
interface TechDebtItem {
id: string;
title: string;
severity: "critical" | "high" | "medium" | "low";
owner: string;
effort: "high" | "medium" | "low";
addedDate: string;
context: string;
linkedRfc: string | null;
}Ownership Boundaries and Team Topologies
Unclear ownership is one of the most expensive problems in large frontend organisations. When no one is accountable for a file, a component, or a pattern, quality degrades, incidents take longer to resolve, and architectural drift accelerates. Explicit ownership solves this — but it requires being deliberate about both who owns what and how ownership transfers.
CODEOWNERS
# CODEOWNERS — required reviewer assignments for pull requests.
# Format: path pattern [space] @team-or-username
# Monorepo shared packages: platform team owns
/packages/ui/ @my-org/platform-team
/packages/api-client/ @my-org/platform-team
/packages/config/ @my-org/platform-team
# Application features: stream-aligned team ownership
/apps/web/src/features/catalog/ @my-org/catalog-team
/apps/web/src/features/cart/ @my-org/cart-team
/apps/web/src/features/checkout/ @my-org/checkout-team
/apps/web/src/features/account/ @my-org/account-team
# Shared app infrastructure: all changes require platform review
/apps/web/src/shared/ @my-org/platform-team
/apps/web/middleware.ts @my-org/platform-team
/apps/web/app/layout.tsx @my-org/platform-team @my-org/frontend-leads
# CI/CD: devex team owns
/.github/ @my-org/devex-team
/turbo.json @my-org/devex-team
# Architecture docs and RFCs: frontend leads review
/docs/rfcs/ @my-org/frontend-leads
/docs/decisions/ @my-org/frontend-leadsTeam Topologies for Frontend
Team Topologies provides a vocabulary for organising engineering teams around flow of value. The most applicable patterns for frontend organisations at scale:
Stream-aligned teams
Own end-to-end delivery of a product domain: catalog, checkout, account. Full ownership from design through deployment. Primary delivery teams in the org.
Platform team
Owns shared infrastructure: component library, shared hooks, API client, CI/CD, design tokens. Reduces cognitive load for stream teams. Does not own product features.
Enabling team
Time-limited: embeds with stream teams to introduce new practices (accessibility improvements, performance tooling, testing patterns). Transfers knowledge, then disengages.
Complicated subsystem team
Owns a high-complexity subsystem that requires specialist knowledge: real-time collaborative editing, video playback, data visualisation. Exposes a stable API to stream teams.
Cross-Team Contribution Model
# Contributing to @my-org/ui
## Who can contribute
All engineers at my-org can contribute. The platform team owns and reviews all changes.
## For bug fixes
1. Open an issue with reproduction steps.
2. Assign to yourself or ask the platform team to assign it.
3. Open a PR. All CI checks must pass. One platform team approval required.
## For new components
1. Raise a proposal in #design-system-proposals Slack channel first.
2. Get sign-off from a designer and the platform team lead.
3. Open an RFC if the component requires new design tokens or API patterns.
4. Implement with Storybook stories and at least 80% test coverage.
5. Two approvals required (one platform team, one from another stream team).
## For breaking changes
1. Open an RFC. ARB review required.
2. Run a codemod for automated migration where possible.
3. Deprecation period of one minor version before breaking change lands.
4. Update all apps in the monorepo as part of the same PR.
## SLA
Platform team reviews within 2 business days. Escalate in #platform-team if blocked.Team Scaling Strategies
Scaling a frontend team is not just about hiring more engineers. It is about building the systems — documentation, standards, tooling, mentoring structures, and shared understanding — that allow new engineers to be effective quickly and senior engineers to have leverage beyond their own output.
Onboarding at Scale
# Week One: Frontend Engineering Onboarding
## Day 1: Environment and orientation
- [ ] Dev environment setup (follow docs/setup.md)
- [ ] First PR: add name to ENGINEERS.md
- [ ] Walk through the monorepo structure with your team lead
- [ ] Read the three most recent merged RFCs
## Day 2-3: Domain deep-dive
- [ ] Read the architecture overview for your assigned feature domain
- [ ] Pair with a teammate on an in-flight bug fix
- [ ] Review five recent merged PRs in your domain
- [ ] Shadow a production deployment
## Day 4-5: First contribution
- [ ] Pick a "good first issue" from your team's backlog
- [ ] Implement, test, and get the PR reviewed
- [ ] Attend team standup and retrospective
## End of week 1 goals
- Can run the full test suite and make a change that passes CI
- Understands the feature boundary model and where your team's code lives
- Has shipped at least one small change to production
## Resources
- Architecture overview: docs/architecture/README.md
- Coding standards: docs/standards/frontend.md
- Team RFCs: docs/rfcs/
- Your buddy: [assigned at start of week]Senior Engineer Leverage
At scale, senior engineers multiply team output through system-building and knowledge transfer — not by personally solving problems faster. The highest-leverage activities shift as a team grows.
| Activity | Small team (1-5) | Scaled team (10+) |
|---|---|---|
| Coding | High — directly delivering features matters most | Reduced — focus on high-complexity or cross-cutting work |
| Standards and tooling | Informal — norms shared through proximity | Critical — enforced through lint rules, templates, CI |
| Code review | Reviews all PRs | Reviews selectively — focuses on architecture and cross-cutting |
| Documentation | Nice to have | Required — ADRs, architecture docs, onboarding guides |
| Mentoring | Informal pairing | Structured: learning plans, 1:1s, deliberate skill transfer |
Knowledge Distribution
// Identify files with only one contributor — knowledge silos.
// Run as a health check on PRs or periodically in CI.
import { execSync } from "child_process";
interface FileSilo {
path: string;
contributor: string;
lastModified: string;
}
function findKnowledgeSilos(directory = "src", threshold = 1): FileSilo[] {
const files = execSync(`git ls-files ${directory}`)
.toString()
.split("\n")
.filter(Boolean);
const silos: FileSilo[] = [];
for (const file of files) {
const contributors = execSync(`git log --format="%ae" -- "${file}"`)
.toString()
.split("\n")
.filter(Boolean);
const unique = new Set(contributors);
if (unique.size <= threshold) {
const lastModified = execSync(`git log -1 --format="%as" -- "${file}"`)
.toString()
.trim();
silos.push({
path: file,
contributor: [...unique][0],
lastModified,
});
}
}
return silos;
}Release Workflows and Deployment
Release workflow design directly determines whether multiple teams can deploy independently. The single most effective change most teams can make is adopting trunk-based development with feature flags. It decouples code integration from feature activation, eliminating the coordination overhead of long-lived feature branches.
Feature Flags
// Typed feature flag system — works with any provider (LaunchDarkly, Unleash,
// custom SSE endpoint, or static config for simpler setups).
export type FeatureFlags = {
"new-checkout-flow": boolean;
"ai-search-suggestions": boolean;
"product-reviews-v2": boolean;
"cart-persistence": boolean;
};
type FlagName = keyof FeatureFlags;
// Flag client interface — injectable for testing
export interface FlagClient {
isEnabled<K extends FlagName>(flag: K, context?: FlagContext): boolean;
}
interface FlagContext {
userId?: string;
orgId?: string;
email?: string;
}
// React hook for consuming flags
export function useFlag<K extends FlagName>(flag: K): boolean {
const client = useFlagClient(); // from FlagProvider context
const { userId } = useSession();
return client.isEnabled(flag, { userId });
}
// Provider pattern — wraps the actual flag client (LaunchDarkly, Unleash, etc.)
export function FlagProvider({
client,
children,
}: {
client: FlagClient;
children: React.ReactNode;
}) {
return (
<FlagContext.Provider value={client}>
{children}
</FlagContext.Provider>
);
}
// Usage in components — flag name is typed, refactor-safe
function CheckoutButton() {
const useNewFlow = useFlag("new-checkout-flow");
return useNewFlow ? <NewCheckoutFlow /> : <LegacyCheckoutFlow />;
}Trunk-Based Development
Trunk-based development (TBD) means all engineers integrate to the main branch at least daily. Long-lived feature branches are eliminated. Incomplete features are hidden behind feature flags. The result is that the main branch is always releasable, and any team can deploy at any time without coordinating with others.
TBD practices
- Feature branches live for hours or days, not weeks
- Main branch passes CI at all times
- Incomplete features are feature-flagged, not branched
- Each PR is small and reviewable in under 30 minutes
- Revert PRs are treated as first-class operations
- Deployments are automated on merge to main
Prerequisites for TBD
- Fast CI (under 10 minutes for the critical path)
- Feature flag infrastructure in place
- Automated rollback capability in deployment pipeline
- Strong test coverage at the feature boundary level
- Engineers trained to split work into small, safe increments
- Monitoring in place to detect regressions quickly
Canary and Progressive Delivery
// Progressive rollout: route a percentage of traffic to a new experience.
// Works with feature flags that support user-segment targeting.
interface RolloutConfig {
flag: string;
percentage: number; // 0-100
sticky: boolean; // same user always gets same variant
}
// Middleware-level rollout (Next.js)
export function getExperimentVariant(
userId: string,
rollout: RolloutConfig
): "control" | "treatment" {
if (!rollout.sticky) {
return Math.random() * 100 < rollout.percentage ? "treatment" : "control";
}
// Sticky assignment: hash userId + flag name for consistent bucketing
const hash = simpleHash(userId + rollout.flag);
const bucket = hash % 100;
return bucket < rollout.percentage ? "treatment" : "control";
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}Documentation Standards
Documentation is an architectural decision. Insufficient documentation creates knowledge silos. Over-documenting creates maintenance burden. The right system documents decisions and structures (which have long shelf lives) rather than implementations (which change constantly and are self-documenting in good code).
The Documentation Stack
| Type | Format | Audience | Update cadence |
|---|---|---|---|
| Architecture overview | Markdown + diagrams (C4 model) | All engineers, new joiners | Major architectural changes |
| ADRs | Structured Markdown (see RFC format) | All engineers | Every significant decision |
| Component docs | Storybook + JSDoc | Consumers of the component library | Every component change |
| API contracts | OpenAPI / tRPC types (auto-generated) | Frontend engineers integrating with APIs | Every API change (automated) |
| Runbooks | Markdown with step-by-step procedures | On-call engineers | After every incident |
| Onboarding guides | Markdown with checklists | New joiners | Quarterly review |
C4-Inspired Architecture Overview
# Frontend Architecture Overview
## System context
The frontend is a Next.js 16 monorepo application serving consumer and admin
experiences. It communicates with a microservices backend via REST APIs and a
GraphQL federation gateway. Real-time features use Server-Sent Events.
## Container view
- apps/web: Main consumer-facing application (Next.js 16, App Router)
- apps/admin: Internal admin dashboard (Next.js 16)
- packages/ui: Shared component library (@my-org/ui, tsup-built)
- packages/api-client: Typed API client (@my-org/api-client, auto-generated from OpenAPI)
## Feature domains (component view)
- catalog: Product discovery, search, filtering (catalog-team)
- cart: Purchase intent, cart management (cart-team)
- checkout: Transaction flow, payment, confirmation (checkout-team)
- account: User profile, order history, preferences (account-team)
## Key decisions
- ADR-012: Feature-based directory structure (see docs/decisions/012-feature-structure.md)
- ADR-019: TanStack Query for server state (see docs/decisions/019-server-state.md)
- ADR-023: Zod for runtime validation (see docs/rfcs/0023-adopt-zod-validation.md)
- ADR-031: Trunk-based development with feature flags (see docs/decisions/031-tbd.md)
## Data flow
User request -> Next.js middleware (auth, feature flags)
-> App Router layout (global providers, session)
-> Feature page (domain-specific data fetching via TanStack Query)
-> API client (@my-org/api-client) -> Backend servicesPerformance at Scale
Performance in large applications is as much a governance problem as a technical one. Individual teams making locally reasonable decisions (adding a charting library, a date picker, a rich text editor) produce globally unreasonable bundle sizes. Without a performance budget system, no one is accountable for the aggregate.
Bundle Splitting Strategy
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: [
"@my-org/ui",
"date-fns",
"recharts",
"lucide-react",
],
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.splitChunks = {
chunks: "all",
cacheGroups: {
// Separate vendor chunk per feature domain — teams own their bundle cost
catalogVendors: {
name: "vendors-catalog",
test: /[\/]features[\/]catalog[\/]/,
priority: 30,
},
checkoutVendors: {
name: "vendors-checkout",
test: /[\/]features[\/]checkout[\/]/,
priority: 30,
},
// Shared component library in its own chunk
ui: {
name: "ui-library",
test: /[\/]node_modules[\/]@my-org[\/]ui[\/]/,
priority: 40,
},
// Framework chunk: rarely changes, high cache hit rate
framework: {
name: "framework",
test: /[\/]node_modules[\/](react|react-dom|next)[\/]/,
priority: 50,
},
},
};
}
return config;
},
};
export default nextConfig;Performance Budgets
// Fail CI if any bundle chunk exceeds its budget.
// Run after next build to check .next/analyze/ output.
interface BundleBudget {
name: string;
maxKB: number;
critical: boolean;
}
const budgets: BundleBudget[] = [
{ name: "framework", maxKB: 120, critical: true },
{ name: "ui-library", maxKB: 80, critical: true },
{ name: "vendors-catalog", maxKB: 60, critical: false },
{ name: "vendors-checkout", maxKB: 100, critical: true }, // payment libs allowed more
{ name: "main", maxKB: 200, critical: true },
];
// Parse the .next/build-manifest.json and check each chunk size.
// Exit 1 (fail CI) if any critical budget is exceeded.
// Warn (but pass) for non-critical overages.Third-Party Impact Governance
bundlephobiaand compare it against the performance budget for the feature area. A library that adds 40 KB to the checkout bundle needs explicit sign-off from the checkout team's performance owner.# Check the bundle cost of a dependency before adding it
npx bundlephobia recharts
# or: yarn dlx bundlephobia recharts
# Audit all dependencies for size and treeshakeability
npx bundlephobia --all package.json
# Analyse the current Next.js bundle composition
ANALYZE=true npm run build
# or: ANALYZE=true yarn build
# Opens @next/bundle-analyzer in the browserTesting at Scale
Testing strategy in large codebases must account for the fact that not all tests are equal in cost and value. A well-designed test pyramid keeps the expensive, slow-running tests at the top (E2E, cross-feature integration) and the fast, cheap tests at the bottom (unit, component). The goal is fast feedback in CI without sacrificing confidence.
Test Architecture for Large Codebases
| Layer | What to test | Tools | Ownership | CI target |
|---|---|---|---|---|
| Unit | Pure functions, Zod schemas, utilities, business logic | Vitest | Feature team | < 30s |
| Component | UI behaviour, accessibility, user interactions | Vitest + RTL | Feature team | < 2 min |
| Integration | Feature-level data flow, API mocking, hook chains | Vitest + MSW | Feature team | < 5 min |
| Contract | API shape agreements between frontend and backend | Pact, Zod schema assertions | Shared (platform) | < 5 min |
| E2E | Critical user journeys end-to-end | Playwright | QA / platform | < 15 min (sharded) |
CI Sharding for Large Test Suites
name: Sharded Tests
on: [push, pull_request]
jobs:
vitest-sharded:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# or: yarn install --frozen-lockfile
- name: Run tests (shard ${{ matrix.shard }} of 4)
run: npx vitest run --reporter=junit --outputFile=results/shard-${{ matrix.shard }}.xml --shard=${{ matrix.shard }}/4
playwright-sharded:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npx playwright install --with-deps
# or: yarn install --frozen-lockfile && npx playwright install --with-deps
- name: Run E2E (shard ${{ matrix.shard }} of 3)
run: npx playwright test --shard=${{ matrix.shard }}/3Contract Testing Between Teams
// Contract test: assert that the API client's expected shape matches
// the actual backend response. Catches API drift early without full E2E.
// Run in CI against a staging environment or a recorded interaction.
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { createApiClient } from "@my-org/api-client";
import { catalogProductSchema } from "@my-org/types/schemas";
const server = setupServer(
http.get("https://api.example.com/v1/products", () =>
HttpResponse.json({
data: [
{
product_id: "prod_001",
product_name: "Test Widget",
price_cents: 2999,
inventory_status: "IN_STOCK",
url_slug: "test-widget",
category_code: "ELECTRONICS",
images: [{ url: "https://cdn.example.com/widget.jpg" }],
average_rating: 4.2,
},
],
total: 1,
})
)
);
beforeAll(() => server.listen());
afterAll(() => server.close());
describe("products API contract", () => {
it("response matches the expected schema", async () => {
const client = createApiClient({ baseUrl: "https://api.example.com" });
const products = await client.catalog.listProducts({});
expect(products.length).toBeGreaterThan(0);
for (const product of products) {
// Zod parse — throws if the shape diverges from the contract
const result = catalogProductSchema.safeParse(product);
expect(result.success).toBe(true);
}
});
});Anti-Patterns
The following patterns are common in large frontend codebases. Each starts as a reasonable local decision and becomes a structural liability at scale.
Global Shared State Across Teams
// ANTI-PATTERN
// One Zustand store holds state for every feature.
// As the codebase grows, every team edits the same file.
// State shape changes break other teams. No clear ownership.
const useStore = create<{
user: User | null;
cart: CartItem[];
products: Product[];
checkoutStep: number;
adminFilters: AdminFilters;
// ... 40 more fields from 8 different teams
}>((set) => ({ /* ... */ }));
// CORRECT: Feature-scoped stores with domain events for cross-feature updates
// features/cart/stores/cartStore.ts
export const useCartStore = create<CartState>((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
clearCart: () => set({ items: [] }),
}));
// Cross-feature communication via domain events, not shared state
// features/checkout/hooks/useCheckoutComplete.ts
export function useCheckoutComplete() {
return useMutation({
mutationFn: submitOrder,
onSuccess: (order) => {
emit("checkout:completed", { orderId: order.id, total: order.total });
// Cart feature listens to this event and clears itself
// No direct dependency between checkout and cart
},
});
}God Components
// ANTI-PATTERN
// A page component that fetches, transforms, and renders everything.
// Untestable in isolation. Impossible for multiple teams to contribute to.
// Every change to any part risks breaking the whole.
export default function ShopPage() {
const [products, setProducts] = useState([]);
const [cart, setCart] = useState([]);
const [user, setUser] = useState(null);
const [filters, setFilters] = useState({});
const [checkoutOpen, setCheckoutOpen] = useState(false);
// ... 300 lines of interleaved fetch, state, and render logic
}
// CORRECT: Page as composition of owned feature modules
// app/(shop)/page.tsx
export default function ShopPage() {
// Page owns layout and orchestration only
// Each feature module is independently tested and owned
return (
<ShopLayout>
<SearchBar /> {/* catalog-team */}
<ProductFilters /> {/* catalog-team */}
<ProductGrid /> {/* catalog-team */}
<CartDrawer /> {/* cart-team */}
</ShopLayout>
);
}Circular Dependencies
// ANTI-PATTERN
// features/cart/hooks/useCart.ts
import { useProducts } from "@/features/catalog/hooks/useProducts"; // cart -> catalog
// features/catalog/components/ProductCard.tsx
import { useCartItem } from "@/features/cart/hooks/useCart"; // catalog -> cart
// This circular dependency means:
// - Neither feature can be built without the other
// - Changes to either feature can unexpectedly affect the other
// - Testing either feature in isolation requires mocking the other
// CORRECT: Break the cycle with domain events or props
// features/catalog/components/ProductCard.tsx
interface ProductCardProps {
product: CatalogProduct;
onAddToCart?: (productId: string) => void; // behavior injected by page
}
// features/cart/hooks/useCart.ts
// Subscribes to domain events instead of importing from catalog
useEffect(() => {
return on("catalog:add-to-cart", ({ productId, quantity }) => {
addItem({ productId, quantity });
});
}, []);The Shared Breaking Change
// ANTI-PATTERN
// packages/ui/src/components/Button.tsx
// Renamed prop without deprecation or codemod.
// Breaks 47 usages across 4 apps silently (TypeScript will catch it, but CI
// for consuming apps was not run, so the merge went through).
// Before:
interface ButtonProps { variant: "primary" | "secondary" }
// After (breaking change released as a patch):
interface ButtonProps { appearance: "primary" | "secondary" } // renamed without warning
// CORRECT: Follow the deprecation lifecycle
// Step 1: Add new prop, deprecate old (minor version)
interface ButtonProps {
/** @deprecated Use 'appearance' instead. Will be removed in v3.0.0. */
variant?: "primary" | "secondary";
appearance?: "primary" | "secondary";
}
// Step 2: In the component, accept both with a dev-only warning
const resolvedAppearance = appearance ?? variant;
if (process.env.NODE_ENV === "development" && variant !== undefined) {
console.warn("[ui] Button: 'variant' prop is deprecated. Use 'appearance'.");
}
// Step 3: Run codemod across the monorepo as part of the deprecation PR
// Step 4: Remove 'variant' in the next major versionUnowned Code
Code with no clear owner is the most expensive technical debt in a large codebase. It cannot be safely changed, it is never improved, and it becomes the source of the bugs no one wants to investigate. Every file in the repository should have a team or individual owner in CODEOWNERS. “Shared” is not an owner. If it is used by multiple teams, one team is primary owner, and others are reviewers.
Without Next.js
All the architecture patterns in this guide apply equally to Vite SPA organisations. The tooling choices differ (Nx instead of Turborepo is a stronger fit for large Vite monorepos, Vite Plugin Federation for MFEs), but feature boundaries, domain events, governance, and ownership models are framework-agnostic.
Nx Monorepo for Vite Projects
# Create an Nx workspace with multiple Vite apps
# npm
npx create-nx-workspace@latest my-org --preset=react-monorepo --framework=react --bundler=vite
# yarn
yarn create nx-workspace@latest my-org --preset=react-monorepo --framework=react --bundler=vite
# Add a new application to an existing workspace
npx nx generate @nx/react:app apps/admin --bundler=vite --routing=true
# or: yarn nx generate @nx/react:app apps/admin --bundler=vite --routing=true
# Generate a shared library
npx nx generate @nx/react:lib packages/ui --buildable --publishable --importPath=@my-org/ui
# or: yarn nx generate @nx/react:lib packages/ui --buildable --publishable --importPath=@my-org/ui
# Run only affected tests (key CI performance advantage over Turborepo)
npx nx affected -t test --base=main
# or: yarn nx affected -t test --base=main{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"targetDefaults": {
"build": { "dependsOn": ["^build"], "cache": true },
"test": { "cache": true },
"lint": { "cache": true }
},
"generators": {
"@nx/react": {
"library": { "linter": "eslint", "unitTestRunner": "vitest" },
"application": { "linter": "eslint", "e2eTestRunner": "playwright" }
}
}
}{
"name": "ui",
"tags": ["scope:shared", "type:ui"],
"targets": {
"build": { "executor": "@nx/vite:build" },
"test": { "executor": "@nx/vite:test" }
}
}
// .eslintrc.json — enforce the tag-based boundary rules
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:catalog",
"onlyDependOnLibsWithTags": ["scope:shared", "scope:catalog"]
},
{
"sourceTag": "scope:checkout",
"onlyDependOnLibsWithTags": ["scope:shared", "scope:checkout"]
},
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:ui", "type:util", "type:data-access"]
}
]
}
]
}
}Module Federation with Vite and TanStack Router
import { createRouter, createRoute, createRootRoute, lazy } from "@tanstack/react-router";
const rootRoute = createRootRoute({ component: AppShell });
// Lazy-loaded MFE routes — each remote app owns its route subtree
const catalogRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/catalog",
component: lazy(() =>
import("catalog/CatalogApp").then((m) => ({ default: m.CatalogApp }))
),
});
const checkoutRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/checkout",
component: lazy(() =>
import("checkout/CheckoutApp").then((m) => ({ default: m.CheckoutApp }))
),
});
export const router = createRouter({
routeTree: rootRoute.addChildren([catalogRoute, checkoutRoute]),
});