React / Performance and trustworthy boundaries / Lesson 15
Server rendering and hydration contracts
Produce deterministic initial markup and attach client behavior through the correct hydration boundary.
On this page
Lesson goals
- SSR and hydration are related stages
- Render a deterministic initial view
- Verify server and client as one boundary
SSR and hydration are related stages
Server rendering can produce initial HTML before browser JavaScript runs. Hydration then attaches React’s client behavior to compatible server-generated markup. This differs from createRoot mounting a new client-only tree into an empty container. A framework often coordinates the server renderer, serialized inputs, client bundle, and hydration call, so application components should follow that integration rather than add a second root. SSR is not the same as React Server Components, which introduce a separate execution and module boundary. A page may use one, both, or neither depending on its architecture and supported framework.
The first client tree must agree
hydrateRoot expects the first client render to match the HTML produced by React on the server under the same inputs. Treat mismatches as defects rather than relying on recovery to patch every attribute correctly. Common causes include time or random values during render, locale differences, invalid HTML nesting repaired by the browser, and conditionals based on window that choose another initial tree. Extensions or external scripts can also alter markup, so inspect the actual evidence. Do not hide every warning with suppressHydrationWarning; it is a narrow escape hatch with limitations, not a repair for an inconsistent application contract.
Render a deterministic initial view
The first example accepts a reviewed display date as data and does not read the wall clock during render. A server and client given that same string produce the same initial text. The module is a component, not a complete server setup; isolated verification can render it with react-dom/server and hydrate the resulting HTML using the same component and serialized inputs. This proves one local fixture, not a full framework deployment. Browser-specific personalization can happen after hydration through a deliberate update, but its loading and layout effects should be visible and tested rather than disguised as identical initial content.
import { useState } from "react";
function Report({ displayDate }) { const [open, setOpen] = useState(false); return <section><h2>Report date: {displayDate}</h2><button type="button" onClick={() => setOpen(x => !x)}>Toggle details</button>{open && <p>Local details</p>}</section>; }
export default function App() { return <main><h1>Deterministic report</h1><Report displayDate="2026-10-25" /></main>; }
Initial: Report date: 2026-10-25. Toggle details reveals Local details.
The date is an explicit shared input. After matched server rendering and hydration, the button can reveal local details.
Generate relationships with hydration-safe ids
The second example uses useId to connect a label and description to its input without a hard-coded id collision when the component repeats. The server and client tree must still agree, and multiple-root identifier prefixes must be coordinated where the integration uses them. useId is not a source of list keys; keys come from stable domain identity. A generated accessibility id and a record id solve different problems. Avoid generating random ids during render, which can break label associations and initial markup agreement. Verify the actual label target and description after hydration, not only that the generated strings happen to look unique.
import { useId } from "react";
function Field() { const id = useId(); return <div><label htmlFor={id}>Contact name</label><input id={id} aria-describedby={`${id}-help`} /><p id={`${id}-help`}>Use your preferred name.</p></div>; }
export default function App() { return <main><h1>Generated field relationships</h1><Field /><Field /></main>; }
Two labeled Contact name inputs with distinct matching label targets.
Each field has a distinct associated id while the matching server/client tree preserves the hydration contract. Exact generated strings are not a public output.
Verify server and client as one boundary
Test the same fixture through server rendering, browser parsing, hydration, and an actual interaction. Capture recoverable hydration errors and unexpected console diagnostics. Include locale, timezone, identifier prefixes, missing data, and invalid nesting cases in the deployment’s test plan. Keep secrets out of serialized client props even when they originate on the server. A successful HTML response does not establish that client handlers attached correctly, and a working client-only test does not exercise hydration. Record the matched renderer versions and build context so a package mismatch is not confused with a nondeterministic component.
Common errors and fixes
Hydration text differs on first render
- Why it happens
- Server and client read different nondeterministic inputs.
- How to fix it
- Share deterministic initial data and personalize deliberately later.
- Prevent it next time
- Test timezone and locale assumptions.
Server HTML is replaced unexpectedly
- Why it happens
- A client-only root was used for server-generated markup.
- How to fix it
- Use the integration’s hydrateRoot boundary.
- Prevent it next time
- Test the actual SSR entry path.
useId is used as a row key
- Why it happens
- Accessibility ids were confused with data identity.
- How to fix it
- Use record ids for list keys.
- Prevent it next time
- Keep identity contracts separate.
Check your understanding
1. Why can checking typeof window during render still cause a hydration mismatch?
Show worked solution
If it selects different initial markup on server and client, the first trees disagree even though neither environment throws. Produce the same initial structure, then make a deliberate client update where appropriate, or use the framework’s documented client-only boundary for that content.
2. What must a hydration test add beyond an ordinary client component test?
Show worked solution
Generate server HTML, let the browser parse it, hydrate with matched inputs, observe diagnostics, and activate an attached handler. This checks markup agreement and behavior attachment together. A client-only createRoot test skips the boundary where the mismatch occurs.
Key takeaways
- Match the initial server and client descriptions.
- Hydration differs from client-only mounting and RSC.
- Generated ids are not list identities.