Command Palette

Search for a command to run...

Hectal
PHASE 25Advanced ~27 min· topic 2 of 9

Topic 25.2

Frontend Architecture Building Blocks

In one line

Every frontend design is assembled from the same blocks: a rendering choice, a component tree with state in the right places, a server cache next to a small client store, routes split into chunks, one API layer, a loading and error strategy, a design system and monitoring.

Think of it like this

A restaurant. The dining room is what guests see (the components). The kitchen prepares food (the server). The waiters carry orders between them and know what to do when a dish is late (the API layer). The pantry keeps ingredients ready so the kitchen isn't asked twice (the cache). The menu design is the same on every table (the design system), and the manager watches how long tables wait (monitoring). Each part has one job, and you can design each one separately.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

CSR / SSR / SSG
Where HTML is made: in the browser (client), on the server per request, or once at build time (static).
Streaming
Sending the page in pieces as each part's data is ready, instead of waiting for all of it.
State placement
Deciding which component (or store, or the URL) owns each piece of data.
Server cache
A client-side copy of server data kept by key, refetched when stale. TanStack Query is one.
Client store
A place for state the browser owns, shared across components. Zustand and Redux are stores.
Code splitting
Cutting the JavaScript into chunks so each page downloads only what it needs.
API layer
The one module every network call goes through: auth, timeouts, retries, errors, de-duplication.
Exponential backoff
Waiting longer after each failed retry (100 ms, 200 ms, 400 ms…) so a struggling server can recover.
Design system
Shared design tokens and ready-made accessible components that every screen uses.
RUM
Real user monitoring: measuring speed and errors in real visitors' browsers, not only in tests.

Step by step

01Draw the layers

Most React frontends have the same layers. Views (components) read from the server cache and the client store, and send user actions back. The server cache talks to the API layer, which talks to the network. Drawing this first gives every later decision a home.

Draw the layersdiagram
Rendering diagram…

02Pick a rendering strategy per route

Ask two questions per route: does it need SEO or a fast first paint for anonymous users, and how personal and fresh is its data? The answers point at a strategy.

Pick a rendering strategy per routediagram
Rendering diagram…

03Place each piece of state

List the data on the screen and label each one: server, URL, global client, local, form. Server data goes in the server cache. Anything a user might share or bookmark goes in the URL. Only state many distant components need goes in the global store. Everything else stays local.

04Define the API layer once

Write the rules in one place: which requests retry, how long they may take, what an error looks like. The live example below does this in about 25 lines and shows retries and de-duplication happening.

api.tswhole filetsx
export class ApiError extends Error {
  constructor(public status: number, message: string) { super(message); }
}

export async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
  const res = await fetch(BASE + path, { headers: authHeaders(), signal });
  if (!res.ok) throw new ApiError(res.status, await res.text());
  return res.json() as Promise<T>;
}

05Plan loading and error regions

Wrap each independent region (feed, sidebar, widget) in its own Suspense boundary and error boundary. A slow sidebar then never blocks the feed, and a broken widget shows "Couldn't load, Retry" while the rest works.

Plan loading and error regionsdiagram
Rendering diagram…

06Add the design system and monitoring

Name the shared components (Button, Dialog, Combobox, Toast) and tokens. Then say what you'd measure: LCP, INP and CLS per route with RUM, JavaScript errors with source maps, API error rates, and one product metric per feature. Without monitoring you can't tell whether the design works in real life.

Try it yourself

  1. 1

    Make the server fail more

    In the API layer example, change n === 1 to n <= 3. Predict the console: how many attempts, and what does the page show? (With retries = 2 there are three attempts in total.)

    Then change the 503 to 404. Predict: does it retry? Why should a "not found" never be retried?

  2. 2

    Move state to the wrong home

    In the four-kinds example, replace useSearchParams with useState("name") for the sort. The page still works. What did you lose? (Think: sharing the link, refresh, the Back button.)

  3. 3

    Watch the cache

    Change staleTime: 60000 to staleTime: 0. Predict whether going back to "name" logs a second server line now. Stale data is still shown at once, but a background refetch runs.

Code & diagrams

An API layer with retry, backoff and shared in-flight requests New tab

Two components need the same user, but the server sees one request that fails, one retry after 100 ms, and success. Components never call `fetch`; the API layer owns retries and errors.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

GET /api/user/1 attempt 1 -> 503
GET /api/user/1 attempt 2 -> 200
server calls: 2
Four kinds of state, four homes New tab

The sort lives in the URL, the product list in TanStack Query's cache (keyed by sort), the cart in a Zustand store and the checkbox in local state. Going back to "name" hits the cache: no second server line.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

server: products sorted by name
server: products sorted by price
rows: 3
Monitoring in the real app: Core Web Vitals from real userstsx
// src/monitoring.ts (runs in the browser, not in this preview)
import { onCLS, onINP, onLCP } from "web-vitals";

function send(metric: { name: string; value: number; id: string }) {
  // sendBeacon survives the page being closed
  navigator.sendBeacon("/rum", JSON.stringify({ ...metric, route: location.pathname }));
}

onLCP(send);
onINP(send);
onCLS(send);
// Dashboards then show the 75th percentile per route against the budget:
// LCP <= 2.5 s, INP <= 200 ms, CLS <= 0.1

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Remove the shared in-flight map

In the API layer example, change get to function get(path) { return request(path); } so every component starts its own request.

terminal
$ Console
── what you'll see ──
GET /api/user/1 attempt 1 -> 503
GET /api/user/1 attempt 1 -> 200
GET /api/user/1 attempt 2 -> 200
server calls: 3

Myth vs fact

Myth

Put all state in one global store so it's easy to find.

Fact

A global store re-renders subscribers on changes, mixes server and client concerns, and needs hand-written refetching. Server data belongs in a server cache, shareable state in the URL, and most state stays local.

Myth

SSR is always better than CSR.

Fact

SSR helps first paint and SEO but needs a server, adds hydration cost and time to first byte. A logged-in dashboard often gains little from SSR; a public product page gains a lot.

Myth

Retrying every failed request makes an app reliable.

Fact

Retrying a non-idempotent POST can charge a card twice, and retrying 4xx errors never helps. Retry only safe requests or writes with an idempotency key, with backoff and a limit.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Retries from thousands of clients at once can keep a recovering server down (a thundering herd). Real backoff adds jitter, a random part of the delay, so clients spread out. It's left out of the demo only to keep the logs deterministic.

  • ▸

    Next.js and React Router framework mode move data loading to the route (loaders, Server Components), which fetches in parallel with code and avoids waterfalls (requests that wait for each other). Mention it whenever the design has nested data dependencies (Phase 20.5).

  • ▸

    A design system is also an accessibility strategy: if Combobox and Dialog are correct once (focus, ARIA, keyboard), every product team inherits that (Phase 18.4). Interviewers like hearing that a11y is solved at the component level, not page by page.

  • ▸

    Budgets are enforced in CI, not by good intentions: bundle-size checks fail a pull request that adds too much JavaScript, and Lighthouse CI runs on key routes (Phase 23.4).

Remember this

  1. 1

    Rendering choice decides where HTML is made. CSR (client-side rendering) sends an empty page plus JavaScript that builds the UI in the browser: simple to host, slow first paint, weak for SEO. SSR (server-side rendering) builds HTML per request: fast first paint, good SEO, a server needed. SSG (static site generation) builds HTML once at build time: fastest and cheapest, but data is only as fresh as the last build (ISR, incremental static regeneration, rebuilds pages in the background). Streaming SSR sends the page in pieces as data becomes ready, with Suspense boundaries as the seams. React Server Components (RSC) run some components only on the server so their code never ships to the browser. Phase 20.1 and 20.2 cover each in depth; in a design you pick per route: a marketing page is SSG, a product page is SSR or ISR, a dashboard behind login is often CSR.

  2. 2

    Component tree and state placement. Draw the tree of the main screen, then place each piece of state as low as possible (in the component that uses it) and as high as necessary (the closest common parent of everything that reads it). Phase 15.1 named the kinds: server state (data the server owns: posts, products), URL state (filters, page, selected tab: shareable and survives refresh), global client state (theme, cart before checkout, a draft), local UI state (is this menu open), and form state. Saying which kind each piece is, out loud, is half of a good architecture answer.

  3. 3

    Client store versus server cache. A server cache (TanStack Query, RTK Query, SWR or a framework's loaders) keeps copies of server data by key, de-duplicates requests, marks data stale after a time and refetches it, and handles loading and error status (Phase 14.6). A client store (Zustand, Redux, Context) holds state the browser owns (Phase 15). The classic mistake is copying server data into a client store and then writing your own refetching and invalidation. Keep server data in the server cache; keep the client store small.

  4. 4

    Routing and code splitting. Each route (URL pattern) gets its own chunk of JavaScript, loaded with lazy() or the router's route-level lazy loading (Phase 13, Phase 16.8), so the home page doesn't download the settings page. Nested layouts keep the shell (header, sidebar) mounted while the inner page changes. Prefetching the next route's code and data on hover or when a link becomes visible makes navigation feel instant.

  5. 5

    One API layer. All network calls go through one module that knows the base URL, adds auth headers, sets a timeout with AbortController, retries only safe requests (GET, or writes with an idempotency key, a unique id that lets the server ignore a repeated request) with exponential backoff (waiting longer after each failure), turns every failure into one error type, and shares in-flight requests so two components asking for the same thing cause one call. Components never call fetch directly. If the backend team serves several clients, a BFF (backend for frontend, a small server shaped for one client) or GraphQL can return exactly what a screen needs in one request (see /system-design for API design).

  6. 6

    Loading, error, design system, monitoring. Decide the loading strategy per region (skeletons that match the layout, Suspense boundaries so slow parts don't block fast ones, Phase 14.3) and the error strategy (error boundaries per widget so one failure doesn't blank the page, retry buttons, toasts for failed writes, Phase 14.5 and 22.4). A design system (shared tokens such as colours and spacing, plus accessible primitives such as Button, Dialog and Combobox) makes every screen consistent and builds accessibility in once. Monitoring closes the loop: RUM (real user monitoring) of Core Web Vitals per route, error reporting with source maps, and product analytics (Phase 23.4 and 23.5).

Explain it without notes

01

How do you choose between CSR, SSR, SSG and streaming for a route?

02

What's the difference between a server cache and a client store, and why shouldn't server data live in Redux?

03

What belongs in a frontend API layer?

04

Describe a loading and error strategy for a page with several independent widgets.

Practice

01

Add a timeout to the API layer example: if fakeFetch takes longer than 50 ms, treat it as a failure with status 0 and retry.

02

Add a CartBadge-style component to the four-kinds example that shows the current sort from the URL.

Trade-offs

  • ↔

    A server cache library adds a dependency (roughly 13 KB gzipped for TanStack Query) but removes a lot of hand-written loading, caching and invalidation code. For an app with three requests, a small custom hook may be enough.

  • ↔

    Many Suspense and error boundaries make failures local and loading progressive, but too many make the page pop in piece by piece. Group things that should appear together under one boundary.

Done when you can

  • I can choose a rendering strategy per route and justify it.

  • I can label every piece of state on a screen and give it the right home.

  • I can explain why server data belongs in a server cache.

  • I can describe what a frontend API layer does, including safe retries.

  • I can plan loading and error regions with Suspense and error boundaries.

  • I can name what to monitor once a design ships.