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:
| Strategy | Good for | Cost |
|---|---|---|
| CSR (SPA) | Apps behind a login, dashboards, tools | Weak SEO without extra work, slower first paint, JS-heavy |
| SSG / prerender | Content sites, docs, marketing, blogs | Build-time data only, rebuild to update |
| SSR | Personalised or fast-changing pages that must rank and load fast | A server to run and scale, hydration complexity |
| Streaming SSR / RSC | Large apps wanting fast first paint and less client JS | Newer 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 state | Lives in | Notes |
|---|---|---|
| Server data | A query cache (TanStack Query, RTK Query) | It is a cache of someone else’s state, not yours. Never a global store |
| URL / route state | The router (params, query string) | Anything shareable or bookmarkable: filters, tabs, pagination, the selected item |
| Ephemeral UI | Local component state | Open/closed, hover, the current step. Most state is this |
| Form state | A form library or local state | Scoped to the form, thrown away on submit |
| Cross-cutting app state | Context or a small store | Theme, auth, locale, feature flags. Read widely, written rarely |
| Truly global, frequently updated | A 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:
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:
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 / platformshared/never imports fromfeatures/orapp/.- 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
Resulttype, 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-cruiserormadgein 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.
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:
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:
| Concern | Decide once |
|---|---|
| Auth and session | Where the user lives, how permissions are checked, how a 401 is handled globally |
| Error handling | App-level boundary, per-route boundary, one reporter (Sentry etc.), a standard error UI |
| Analytics and logging | One wrapper, a typed event list, consent handling |
| i18n | Library, message format, how strings are extracted, RTL |
| Feature flags | Client, evaluation timing, a typed flag list, kill-switch behaviour |
| Config and env | Build-time vs runtime; container config if you ship images |
| Theming | Token source of truth, dark mode, no-flash strategy, sharing across modules |
| Accessibility baseline | Semantic components by default, focus management on route change, CI checks |
| Performance budget | Bundle-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/orutils/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:
- Vite plus React plus a router with route-based code splitting and route-level data loading.
- Feature folders, a thin
shared/kernel, and the dependency rule enforced by a lint plugin from day one. - TanStack Query for server state, local state by default, the URL for anything shareable, one small store only if a real need appears.
- A typed API client generated from the backend spec, wrapped in a per-feature
api/module, with response validation at the edge. - A design system in its own directory: tokens plus components, consumed through a boundary.
- An error boundary and a suspense boundary per route, one global error reporter, one analytics wrapper.
- CI runs typecheck, lint with boundary rules, tests, a bundle-size budget, and Lighthouse.
- 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.