How do you architect a large React app for SSR/ISR/CSR?

Design a React architecture that supports SSR, ISR, and CSR with clean routing and no hydration mismatches.
Build a large React architecture with route-level code splitting, stable hydration, and a governed design system that scales safely.

answer

A robust large React app separates concerns by feature, renders on the server (SSR/ISR) with deterministic markup, and hydrates without drift. Use a file- or config-based router with route-level code splitting, suspense boundaries, and data loaders that serialize stable JSON. Constrain side effects to client-only islands, guard time/locale randomness, and share a design system of accessible components and tokens. Validate with hydration warnings enabled and snapshot HTML in CI to catch deltas.

Long Answer

A production-ready React architecture must ship fast first bytes (SSR/ISR), hydrate predictably (no mismatches), and scale with teams. The blueprint is a layered system: routing and data, feature modules, a governed design system, and delivery mechanics that keep hydration deterministic.

1) Routing and rendering modes
Adopt a router that supports server rendering and data preloading (for example, frameworks with server loaders or a custom Express + React Router setup). Model each route with a data loader that returns serializable JSON only—no Dates without ISO strings, no functions, no prototypes. For SSR, render HTML on request. For ISR, cache route HTML for a TTL and revalidate asynchronously. For CSR paths (non-critical or highly interactive tools), lazy-load the route chunk and fetch data client-side.

2) Route-level code splitting and suspense
Every top-level route becomes a split point with React.lazy or framework-level bundling. Wrap each in <Suspense> with a consistent skeleton. Add nested suspense for heavy widgets (charts, editors). Keep fallbacks minimal and accessible. Preload next-route chunks on intent (hover or in-viewport links) to hide latency without bloating initial payload.

3) Hydration determinism
Hydration mismatches happen when server HTML and client render diverge. Eliminate nondeterminism:

  • Time and randomness: format dates/relative time on the client or render ISO strings and format after hydration; seed random with a fixed value per request.
  • Locales: fix locale and number formatting via serialized settings; avoid relying on server defaults.
  • Feature flags and A/B: include resolved flags in the server payload and use identical evaluation logic on the client.
  • User-specific data: render only if authenticated state is known; otherwise, stream a shell and progressively reveal client islands.

4) Data layer and serialization
Define per-route loaders that fetch domain data and return plain objects with primitives and ISO strings. Normalize errors (status, message, code) and set HTTP status in SSR so caches behave. Serialize with JSON.stringify once; avoid double-encoding. In the client, read the same data from a bootstrap script (window.__DATA) or framework context to avoid a second fetch, then revalidate in the background.

5) Feature modules and boundaries
Group code by domain: orders, catalog, auth, each exposing routes, components, server loaders, and tests. Keep components presentational; place effects in hooks/services. Use adapters for APIs so modules share typed contracts. For cross-cutting concerns (auth, i18n, theme), create providers at the app shell and avoid re-renders by memoizing values and splitting contexts by responsibility.

6) Design system and tokens
Ship a versioned design system with accessible primitives (Button, Input, Dialog, Combobox) and tokens (color, spacing, typography, motion) via CSS variables. Provide server-friendly defaults (no layout shift), managed focus, and reduced-motion rules. Document props and states in Storybook and test with visual regression so routes composed from these parts remain consistent.

7) Performance budgets and assets
Set budgets: max initial JS, p75 LCP target, and max route chunk size. Use HTTP/2 or HTTP/3 with server push alternatives removed; prefer preloads for critical CSS and fonts. Inline critical CSS per route, defer the rest. Send images with width/height, responsive srcset/sizes, and modern formats. Avoid hydration-heavy pages by moving peripheral interactivity to client islands (small, isolated components that hydrate independently).

8) Caching and revalidation
For ISR, store rendered HTML keyed by route params and headers that affect content (locale, theme). Use stale-while-revalidate to keep TTFB low. For client queries, configure HTTP caching with ETag and conditional requests. Coalesce identical server fetches per request to avoid thundering herds. Invalidate on writes via webhooks or event buses.

9) Testing and governance
Catch mismatches early: run SSR → hydrate tests in CI that diff server HTML against a headless client render with hydration warnings turned into failures. Add smoke tests per route, contract tests for loaders, and a11y checks. Enforce ADRs for router, data, and design-system decisions, and semver the shared packages. Track Core Web Vitals and hydration timings per route in RUM.

10) Delivery and ops
Deploy behind a CDN; route static assets with immutable caching and HTML with short TTL or ISR. Enable error boundaries per route and instrument logs with route id, loader timings, and serialize size. Canary new routes and measure regressions before global rollout.

This architecture keeps SSR/ISR HTML stable, hydrates safely, and scales with feature teams through clear boundaries, a shared system, and strict runtime contracts.

Table

Area Principle Implementation Outcome
Routing Server-first with loaders Per-route data loaders, serializable JSON, SSR/ISR + CSR paths Fast TTFB, flexible flows
Splitting Route chunks + suspense React.lazy, nested <Suspense>, prefetch on intent Smaller JS, smoother UX
Hydration Deterministic markup Fixed locale/time, seeded random, serialized flags/auth No mismatches
Data One fetch, reuse Bootstrap payload, background revalidate, HTTP status Fewer refetches
Modules Domain boundaries Feature folders, adapters, typed contracts, providers Team scalability
Design system Accessible tokens CSS vars, stable components, Storybook + visual tests Consistent UI
Performance Budgets + images Per-route CSS, responsive media, island hydration Lower LCP/JS cost
Caching ISR + SWR HTML cache keys, ETags, coalesced fetches, invalidation Stable, fresh pages
Testing SSR→hydrate CI Hydration-fail as error, loader contracts, a11y checks Early regression catch
Ops CDN + canary Short TTL/ISR, observability, route-level error boundaries Safe rollout

Common Mistakes

Rendering time-, locale-, or random-dependent markup on the server and different logic on the client, causing hydration mismatches. Serializing non-JSON values (Dates, Maps) and expecting identical client shapes. Mixing SSR and client-only effects in the same component, so useEffect changes DOM before hydration completes. Overusing global providers that trigger full-app re-renders. Skipping route-level code splitting and shipping a megabundle. Doing a second fetch for data already serialized into HTML. Streaming client-side feature flags that differ from server decisions. Rendering authenticated content without a stable session, then flickering to a signed-out shell. Omitting ETags and coalescing, so caches thrash. No CI that fails on hydration warnings. Heavy modals and dialogs that mount during hydration and shift layout. Inconsistent tokens between design and code, creating visual drift.

Sample Answers

Junior:
“I split routes with React.lazy and wrap them in <Suspense>. I render HTML with SSR, serialize plain JSON data, and read it on the client to avoid a second fetch. I keep time and locale formatting client-side to prevent hydration mismatch, and I use a shared component library.”

Mid:
“I design feature modules with per-route loaders. For ISR, I cache route HTML and revalidate in the background. I fix locale/time/flags in the server payload and use identical logic in the client. I add nested suspense for heavy widgets, responsive images, and per-route critical CSS. CI fails on hydration warnings and visual regressions.”

Senior:
“I operate a hybrid SSR/ISR/CSR architecture. Determinism is enforced by a serialization contract, seeded randomness, and resolved feature flags. Route-level code splitting and island hydration keep JS small. HTML caches use keys (route params + locale), client queries use ETags. ADRs govern router/data/design system; RUM tracks hydration cost and Web Vitals. Canary routes validate changes before global rollout.”

Evaluation Criteria

Look for a clear plan to support SSR/ISR/CSR with deterministic hydration. Must-haves: per-route loaders returning serializable JSON; fixed locale/time/random seeds; shared flags across server and client; route-level code splitting with Suspense; design system with accessible tokens; and image/CSS optimization. Caching should cover HTML ISR keys and client ETags with coalesced fetches. Testing requires SSR→hydrate CI that treats warnings as failures, plus a11y and visual checks. Red flags: SPA-only thinking, duplicate refetches, global state that forces full-app re-render, mismatched feature flags, or no strategy for Date/Intl differences. Senior answers mention RUM on hydration timings, canary rollouts, ADRs, and semver for shared packages, plus island hydration to minimize client JS.

Preparation Tips

Build a demo with three routes: list, detail, and dashboard. Implement SSR for all, ISR for list/detail with a short TTL, and CSR-only for a heavy dashboard widget. Add per-route loaders that return primitives and ISO strings; serialize once into a bootstrap script. Seed random and pin locale; format dates on the client. Split each route with React.lazy and nested <Suspense>. Inline critical CSS per route and lazy-load the rest. Add responsive images with explicit dimensions. Implement HTML cache keys (params + locale) and client ETag requests. In CI, run an SSR→hydrate test that fails on mismatches, plus visual and a11y tests. Track Web Vitals and hydration time in a RUM dashboard. Write one ADR documenting serialization rules and one codemod to migrate old date handling to ISO. Canary a change behind a flag and measure LCP and hydration delta before full rollout.

Real-world Context

A marketplace moved to per-route loaders and ISR for catalogs; TTFB improved while keeping data fresh via background revalidate. Hydration mismatches vanished after seeding random, pinning locale, and serializing flags; SSR warnings dropped to zero in CI. A SaaS dashboard adopted island hydration for charts and editors; JavaScript on the critical path fell and LCP improved. An ecommerce site coalesced server fetches and added ETags; origin load during spikes decreased. Visual and a11y tests in Storybook caught a Dialog focus regression before release. With ADRs and semver on the design system, teams upgraded safely. RUM showed p75 hydration cut in half after removing a global provider that forced tree-wide re-renders. Canary rollouts exposed a caching key bug for locale; a quick fix protected SEO while maintaining fast pages.

Key Takeaways

  • Use per-route loaders with strictly serializable data.
  • Enforce deterministic hydration: fixed locale/time/flags/seeds.
  • Split by route with Suspense; prefer island hydration for heavy UI.
  • Optimize images and CSS per route; set clear performance budgets.
  • Cache HTML with ISR keys and client data with ETags; coalesce fetches.

Practice Exercise

Scenario:
You must design a React app serving a catalog, product detail, and analytics dashboard. Requirements: SSR for SEO, ISR for catalog freshness, CSR-only for heavy analytics. Strictly no hydration mismatches and route-level code splitting everywhere.

Tasks:

  1. Routing & Data: Define three routes with loaders that return only primitives and ISO strings. Implement SSR for all; set ISR TTL for catalog and detail with keys (handle, market, locale).
  2. Serialization Contract: Create a utility that stringifies once on the server and hydrates from window.__DATA. Add a linter rule that forbids non-serializable values in loader returns.
  3. Determinism: Pin locale and timezone in a context derived from the server payload; seed randomness per request; defer relative time formatting to the client.
  4. Code Splitting: Wrap each route in React.lazy with <Suspense>; add nested suspense for charts and rich editors. Prefetch the next route chunk on link hover.
  5. Design System: Use tokens via CSS variables; ensure accessible components with zero layout shift and reduced-motion handling.
  6. Caching: Configure HTML ISR with stale-while-revalidate. For client queries, use ETags and conditional GET; coalesce duplicate requests.
  7. CI & RUM: Add SSR→hydrate tests that fail on warnings, plus visual and a11y checks. Instrument hydration duration, LCP, and CLS per route in RUM.
  8. Canary & Rollback: Gate a caching change behind a flag; ship to five percent of traffic; auto-rollback on LCP regression.

Deliverable:
An architecture diagram, a serialization contract doc, CI checks, and a short report with baseline vs. post-optimization LCP and hydration timings, including screenshots of the no-mismatch CI run.

Still got questions?

Privacy Preferences

Essential cookies
Required
Marketing cookies
Personalization cookies
Analytics cookies
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.