Frontend architecture: the decisions that compound and the ones that don't
Architectural decisions at the frontend layer are unusually sticky. Most of them survive in the codebase long after the team that made them has moved on. This guide is about the small handful of decisions that genuinely compound — that make every future change easier or harder depending on the choice — and how to recognise them when you are making them.
Which decisions actually compound
Most things in a frontend codebase are reversible cheaply. A button's styling can be changed in an afternoon. A route can be renamed in an hour. A library can be swapped for a competitor in a sprint. The decisions that compound are the ones that touch the shape of the data that flows through the app — where state lives, what shape it takes, and which layer is allowed to mutate it.
The reason those decisions are sticky is that every component, every page, every feature gets written against the existing data shape. Once a Redux store, a React Context, or a TanStack Query cache has been the source of truth for three months, hundreds of components depend on its specific API. Changing the shape means touching every one of those call sites. Most teams never do.
The corollary is that the cost of getting the data layer wrong is the cost of working around it forever. Teams that picked the wrong state library in year one spend year two and year three building escape hatches — local state that should have been global, global state that should have been local, custom hooks that wrap the library to make it usable. The team's productivity is measurably worse than a team that picked a library that fit the shape of the app from the start, and the difference compounds across every new feature.
Component boundaries are the first architecture
Before any framework or library decision, a frontend codebase makes a series of decisions about where one component ends and another begins. Most of those decisions are made during the first month of the project, by whoever happens to be writing the first few screens, and they shape every later contribution. A codebase where buttons take 18 props because the original author tried to handle every possible variant is a codebase where every new variant adds prop #19. A codebase that composes small primitives instead is a codebase where the same variation is a five-line wrapper at the call site.
The discipline most experienced teams converge on is composition over configuration. A component takes a small number of props that describe what it is, not how it should look in every conceivable context. Variants are built by composing smaller components — slot props, render props, children-as-functions, headless component libraries — rather than by piling configuration onto a single component. The result is a UI layer where new variants do not require touching old code, and where the cost of adding a feature is proportional to the feature's size, not to the codebase's size.
The component-boundaries post in this topic walks through the specific moves: how to spot a component that has accumulated too many props, the refactoring path from a configuration-heavy primitive to a composition-heavy one, and the small set of design tokens that should be passed down rather than re-declared on every component.
State ownership: which layer holds what
The single most common architectural mistake on growing frontend codebases is putting state in the wrong layer. Form inputs end up in global state because someone thought "the rest of the app might need them"; server data lives in component state because nobody set up a cache; transient UI state — which dropdown is open, which row is selected — lives in a Redux store because that was the team's default for everything.
The state-ownership discipline is small. Server data — anything the backend owns — lives in a dedicated server-state library (TanStack Query, SWR, RTK Query, or whatever the framework provides). Client state that is shared across pages — auth, theme, feature flags — lives in a small global store. Client state that belongs to a single component or its immediate children lives in that component's local state. The three layers do not bleed into each other.
Getting this split right at the start removes a category of bug that otherwise haunts the codebase. Stale server data because someone is hand-managing a Redux slice when the cache library would have invalidated correctly. Form inputs that survive across navigations because they were elevated to global state. Modals that don't reset because their open-state was treated as global. The state-ownership post in this topic covers the specific rules each layer should follow, with the refactoring paths to fix codebases that have drifted.
The server/client split is the new architecture
Modern frameworks — Next.js App Router, Remix, SvelteKit — have made the server/client boundary the most consequential single architectural decision in a frontend project. Components run on the server unless they explicitly opt into the client. Data fetching happens server-side by default. The JavaScript bundle that the browser receives is shaped entirely by which components were marked as client.
The compounding decision here is not which framework you use; it is the discipline of keeping client components small. A page where everything is marked client is a page that ships a large JavaScript bundle for no reason. A page where only the genuinely interactive elements are client components — the form, the dropdown, the chart — is a page that loads quickly and hydrates cheaply.
The deep-dive on server/client patterns covers the recurring decisions: when to pass server data through a client component versus fetching it again on the client, when context providers need to be client components, and how to handle the situation where a small piece of interactive state lives inside an otherwise-static page. None of those decisions are obvious in the abstract; they only make sense once you have seen the patterns repeat across enough projects.
Data fetching is the layer that grows the fastest
On most frontend codebases, the data-fetching layer is the part of the architecture that most needs to be deliberate, and the part that most often gets handled ad-hoc. The first version is usually a useEffect with a fetch call. The second version is a wrapped fetch with a loading state and an error state. By the time the team realises the pattern has been copy-pasted into 60 components, the costs of standardising on a real data layer have been paid in scattered bugs.
The right move is to pick a data-fetching library on day one and route every server interaction through it. The library handles caching, invalidation, request deduplication, retries, optimistic updates, and the loading/error state machines that every component otherwise reinvents. The team writes hooks that describe what data they want; the library handles when and how to fetch it. The savings compound across every new feature.
The patterns post in this topic covers the four that show up most often: simple queries with a stable key, paginated lists that need infinite-scroll handling, mutations with optimistic updates, and the cache-invalidation rules that connect mutations back to the queries they affect. None of those are framework-specific. All of them are small, learnable patterns that make a codebase orders of magnitude easier to maintain.
Frontend architecture is not glamorous work. Most of it is small decisions, repeated consistently across the lifetime of a codebase, that decide whether the next feature takes a day or a week. The teams that take it seriously do not write more clever code than the teams that do not. They write code that is easier to read three months later. That is the whole compounding effect, and it is the thread the posts in this topic are written around.