Portfolio/Writing/Front-end architecture: the decisions that are expensive to change

Front-end architecture: the decisions that are expensive to change

Front-end architecture is not folder structure. It is the load-bearing choices: rendering strategy, app topology, the backend contract, where state lives, and the module boundaries. How to make those choices, enforce them, and evolve them.

"Front-end architecture" gets used to mean folder structure, which is the cheap part. The architecture is the set of decisions that are painful to reverse once code depends on them: how you render, how the app is split, how it talks to the backend, where each kind of state lives, and what the module boundaries are. Get those right and the folder layout barely matters. Get them wrong and no amount of reorganising helps.

What front-end architecture actually is#

A useful test: if changing a decision means touching most of the codebase, it is architecture. Renaming a folder is not. Switching from client rendering to server rendering is. Moving auth from a context to a library is. Changing the shape of your API responses is.

Architecture is the decisions you wish you could get right early, because they are the ones you cannot cheaply change later.

So the job is to identify those decisions, make them deliberately, write down why, and put guardrails around them so they do not erode.

The load-bearing decisions#

Rendering strategy

This one cascades into everything else: routing, data fetching, SEO, caching, the shape of your components. The options:

StrategyGood forCost
CSR (SPA)Apps behind a login, dashboards, toolsWeak SEO without extra work, slower first paint, JS-heavy
SSG / prerenderContent sites, docs, marketing, blogsBuild-time data only, rebuild to update
SSRPersonalised or fast-changing pages that must rank and load fastA server to run and scale, hydration complexity
Streaming SSR / RSCLarge apps wanting fast first paint and less client JSNewer mental model, framework lock-in, infra

Pick based on who the users are and whether crawlers and first paint matter. A tool behind auth is fine as a pure SPA. A storefront is not. Details on the SPA case are in the SPA SEO post.

App topology

How the app is split, and whether the pieces deploy together:

  • Monolith SPA. One build, one deploy. The right default for one team.
  • Modular monolith. One build, but strict internal module boundaries so it could be split later. Most apps should be here.
  • Micro-frontends. Independent builds and deploys per team. Real coordination cost. Only when independent deploy cadence is a current constraint, not an aspiration.

The integration-patterns post covers the choices there, and the Module Federation post covers the runtime one in depth. The short version: start as a modular monolith, split only when the org forces it.

The backend contract

How tightly the front end is coupled to backend shapes decides how often a backend change breaks you.

  • Generate a typed client from a spec (OpenAPI, GraphQL schema) so a contract change is a compile error, not a runtime surprise.
  • Validate responses at the edge of the app (zod or similar) so bad data fails loudly in one place.
  • Keep an anti-corruption layer: API shapes map to view models your components consume, so a field rename does not ripple through the UI.
  • Consider a BFF (backend-for-frontend) when the front end needs data aggregated or reshaped and you do not want that logic in the client.

Where state lives

The most common architectural mistake is one big global store holding everything. Different kinds of state have different owners:

Kind of stateLives inNotes
Server dataA query cache (TanStack Query, RTK Query)It is a cache of someone else’s state, not yours. Never a global store
URL / route stateThe router (params, query string)Anything shareable or bookmarkable: filters, tabs, pagination, the selected item
Ephemeral UILocal component stateOpen/closed, hover, the current step. Most state is this
Form stateA form library or local stateScoped to the form, thrown away on submit
Cross-cutting app stateContext or a small storeTheme, auth, locale, feature flags. Read widely, written rarely
Truly global, frequently updatedA store with selectors (Zustand, Jotai)Rare. Reach for it only when context would cause re-render storms

More on the server-cache layer in the caching post, and on the cross-cutting layer (theme, locale, sharing it across modules without prop drilling) in the storage post.

Styling and the design-system boundary

Decide the styling approach once (utility CSS, CSS modules, a CSS-in-JS library, plain CSS with tokens) and do not mix three. More important than the approach: put the design system behind a boundary. A separate package or directory that exports tokens and components, that the app consumes and never reaches around. When the design system is a real boundary, a visual refresh is a version bump, not a search and replace.

Structure inside the app#

Feature slices, not layer folders

Organising by technical layer (components/, hooks/, services/, utils/) means every feature is smeared across every folder, and deleting a feature is archaeology. Organise by feature instead, with a small shared layer underneath:

text
src/
  app/                 # composition root: router, providers, layout shell
  features/
    invoices/
      api/             # data access for this feature only
      components/
      hooks/
      model/           # types, view models, pure logic
      routes/
      index.ts         # the ONLY public entry point for this feature
    billing/
    settings/
  shared/              # the shared kernel, downstream of everything
    ui/                # design-system components / re-exports
    lib/               # http client, date, money, result types
    auth/
    i18n/
    config/
  test/

A feature owns its screens, its data access, its local state, and its types. It exposes a narrow public API through its index.ts. Everything a feature needs that is not feature-specific comes from shared/.

The dependency rule

Dependencies point one way:

text
        app/            (composition root, knows about everything)
          |
          v
      features/          (invoices, billing, settings ...)
          |              features do NOT import each other
          v
       shared/           (ui, lib, auth, i18n, config)
          |
          v
     third-party / platform
Dependencies flow downward. Features never import each other. The app layer wires features together.
  • shared/ never imports from features/ or app/.
  • A feature never imports another feature's internals. If two features need the same thing, it moves to shared/, or one exposes it through its public API and the other depends on that explicitly.
  • app/ is the only place that knows the full picture. It wires routes, providers, and the layout together.

The shared kernel

The set of things every feature depends on. Design it once, keep it small, and version it like a library even if it lives in the same repo:

  • The design system: tokens plus components.
  • The HTTP client: base URL, auth header, error normalisation, retries.
  • Auth and session: the current user, permissions, login and logout.
  • i18n, config and env access (runtime config if you deploy in containers).
  • Error reporting, analytics, and the app-level error boundary.
  • Shared primitives: a Result type, money and date helpers, a typed event bus if you need one.

Keep logic out of components

A component that fetches, transforms, decides, and renders is impossible to test and reuse. Split the responsibilities:

  • Data access in an api/ module that returns typed results.
  • Business rules in pure functions in model/. No React, no fetch. These get the cheap, thorough unit tests (testing post).
  • Orchestration in hooks: call the api module, run the pure logic, expose what the component needs.
  • The component renders props and fires events. That is all.

Wrap third-party SDKs

Every analytics SDK, payment library, map, feature-flag client and auth provider goes behind a thin module you own (a "port"). Components import your analytics.track(), not the vendor's global. When the vendor changes their API or you swap providers, you change one file. It also keeps the vendor out of your unit tests.

Boundaries are contracts, and you enforce them#

A module boundary that is not enforced is a suggestion, and suggestions rot. Make the boundary real:

  • Each feature exports a public API through one index.ts. Deep imports into a feature's internals are a lint error.
  • Enforce the dependency rule with tooling: eslint-plugin-boundaries, import/no-restricted-paths, Nx project tags, or TypeScript project references.
  • Run dependency-cruiser or madge in CI to fail the build on a forbidden edge or a circular dependency.
  • Type the contract with the backend, and validate at the edge, so the boundary between your app and the API is also checked.
Boundaries decay by default
Without a lint rule, someone will import features/billing/hooks/useTaxRate from features/invoices because it was faster that day. Six months later the two features cannot be understood or deployed apart. The rule costs an afternoon to set up and saves that.

One-directional data flow#

Data moves in one direction, and every layer has a single job. A read and a write look like this:

text
READ
  server  ->  query cache  ->  selector / view model  ->  component

WRITE
  component event  ->  mutation  ->  server  ->  invalidate query cache
                                              ->  cache refetches
                                              ->  components re-render
  • Components do not fetch directly. They read from the cache through a hook and fire events.
  • Server data is never copied into a global store. If two components need it, they call the same query key.
  • Derived state is computed, not stored. A filtered list is a selector over the cache, not a second copy you keep in sync.
  • The URL holds anything a user might share or bookmark.

Routing and boundaries as architecture#

The router is the backbone of a front end, not a detail. Design it:

  • Route-based code splitting so the first screen does not ship the whole app. This is the single highest-impact performance decision (performance post).
  • Data loading colocated with routes. A route declares what it needs; the router loads it before or while rendering, which kills request waterfalls.
  • Nested layouts. Shared chrome renders once and persists across child navigations.
  • An error boundary and a suspense boundary per route. A failure or a slow load in one route does not blank the whole app. These boundaries are architectural elements, not afterthoughts.

Cross-cutting concerns you design once#

These touch every feature. Decide each one at the architecture level so features inherit it instead of reinventing it:

ConcernDecide once
Auth and sessionWhere the user lives, how permissions are checked, how a 401 is handled globally
Error handlingApp-level boundary, per-route boundary, one reporter (Sentry etc.), a standard error UI
Analytics and loggingOne wrapper, a typed event list, consent handling
i18nLibrary, message format, how strings are extracted, RTL
Feature flagsClient, evaluation timing, a typed flag list, kill-switch behaviour
Config and envBuild-time vs runtime; container config if you ship images
ThemingToken source of truth, dark mode, no-flash strategy, sharing across modules
Accessibility baselineSemantic components by default, focus management on route change, CI checks
Performance budgetBundle-size limits and Lighthouse thresholds, enforced in CI

Build and deploy topology#

  • Monorepo vs polyrepo. Monorepo for one org shipping together, with project boundaries enforced. Polyrepo only when teams truly own separate release trains.
  • One build vs independent deploys. One build unless independent deploy cadence is a real requirement.
  • The CI graph. Typecheck, lint (including boundary rules), unit and component tests, a bundle-size gate, a Lighthouse check, and a preview deploy per PR.
  • Bundle budgets in CI. A hard limit that fails the PR. Without it, the bundle only grows.

Evaluating and evolving an architecture#

An architecture is not a document you write once. Keep it honest:

  • Fitness functions. Automated checks that encode the architecture: dependency-graph rules, bundle-size budgets, type-coverage thresholds, Lighthouse CI, a test for "no feature imports another feature".
  • ADRs. A short record for each load-bearing decision: the context, the options, the choice, the consequences. When someone asks "why is it like this" in a year, the answer exists. This pairs with a plain-language changelog and workflow discipline.
  • Strangler for change. Big architectural changes happen incrementally, route by route or feature by feature, with both versions running side by side. The migration post is this pattern applied to a framework change.
  • Conway's law. Your architecture will end up shaped like your team structure. If you want a modular monolith, do not staff it as five teams that each need to deploy independently.

Anti-patterns#

  • Treating folder structure as architecture and never deciding the load-bearing things.
  • One global store holding server data, UI state, and form state together.
  • Components that fetch, transform, decide, and render, with no layer between them and the network.
  • A shared/ or utils/ folder that becomes a junk drawer with circular dependencies.
  • Micro-frontends adopted for a single team that just wants cleaner code.
  • Abstractions with exactly one implementation, built for a second one that never arrives.
  • Boundaries with no enforcement, so the dependency graph quietly turns into a ball of mud.
  • Three styling systems in one app because each was "just for this bit".

A pragmatic default#

If you are starting a typical app behind a login, this is a safe base:

  1. Vite plus React plus a router with route-based code splitting and route-level data loading.
  2. Feature folders, a thin shared/ kernel, and the dependency rule enforced by a lint plugin from day one.
  3. TanStack Query for server state, local state by default, the URL for anything shareable, one small store only if a real need appears.
  4. A typed API client generated from the backend spec, wrapped in a per-feature api/ module, with response validation at the edge.
  5. A design system in its own directory: tokens plus components, consumed through a boundary.
  6. An error boundary and a suspense boundary per route, one global error reporter, one analytics wrapper.
  7. CI runs typecheck, lint with boundary rules, tests, a bundle-size budget, and Lighthouse.
  8. An ADR for each load-bearing choice, and a changelog for changes as they land.

This is a modular monolith. It stays simple while it can, and every boundary that would let you split later is already real.

Summary
Front-end architecture is the choices that are expensive to reverse: rendering strategy, app topology, the backend contract, where each kind of state lives, and the module boundaries. Organise by feature with a one-way dependency rule into a small shared kernel, keep logic out of components, wrap every third-party SDK, and enforce the boundaries with lint and CI. Design the cross-cutting concerns once. Record the load-bearing decisions as ADRs, guard them with fitness functions, and change them with a strangler migration, not a rewrite.

Next up
Migrating an Angular app to React with an agent: design first, plan first, route by route

A staged approach to moving an Angular app to React with an agent: audit the current UX, redesign, write the migration plan before any code, keep the Angular app read-only, execute one route at a time against a checklist, and keep a changelog.

Read next →