A hydration mismatch in a Next.js form means React rendered one set of field values, ids, or attributes on the server and a different set on the client’s first paint, so it throws away the server DOM for that subtree and your inputs flash, lose focus, or reset.

This page builds on the synchronization model in hydration sync for SSR forms and its Svelte counterpart, handling Svelte form hydration mismatches. It targets React 18 and the Next.js App Router specifically, where Client Components hydrate under a server-rendered shell and the failure modes are narrower but sharper than in the Pages Router.


Context and prerequisites

Hydration is React reconciling its virtual tree against server-rendered HTML without rebuilding it. The contract is strict: the first client render must produce byte-identical markup to what the server sent. A form breaks that contract whenever its initial render depends on something the server could not see — localStorage, window, the current Date, a random id — or whenever a controlled input is seeded from client-only state. React 18 does not patch the difference silently; it discards the mismatched subtree and re-renders it on the client, which is what you see as a flash or a lost cursor. Everything below keeps that first render deterministic and defers browser-only reads until after commit.


The pattern, as one focused component

"use client";
import { useId, useState, useEffect, useRef } from "react";

interface Props {
  // Server-provided defaults. The server renders the input from these, so the
  // client's first render must use exactly the same values.
  initialEmail: string;
  initialName: string;
}

export function ProfileForm({ initialEmail, initialName }: Props) {
  // useId returns a deterministic id derived from the component's position in
  // the tree — IDENTICAL on server and client. Never use Math.random or a
  // module-level counter here; those diverge between the two render passes.
  const emailId = useId();
  const nameId = useId();

  // Seed controlled state from SERVER props only. This first render matches
  // the server HTML exactly. Do NOT read localStorage/Date/window here.
  const [email, setEmail] = useState(initialEmail);
  const [name, setName] = useState(initialName);

  // A flag that is false during the server render and the first client render,
  // then flips true after hydration. Use it to gate any client-only UI.
  const [hydrated, setHydrated] = useState(false);

  // Track whether the user has typed, so a restored draft never clobbers input.
  const touched = useRef(false);

  useEffect(() => {
    // This effect runs AFTER hydration commits, so it is safe to touch the
    // browser here. Setting state now schedules a second render that the user
    // perceives as the normal post-load state, not a mismatch.
    setHydrated(true);

    // Apply a saved draft only if the user has not started editing and the
    // stored value actually differs from the server default.
    if (!touched.current) {
      const draft = window.localStorage.getItem("profile-draft-email");
      if (draft && draft !== initialEmail) setEmail(draft);
    }
  }, [initialEmail]);

  return (
    <form>
      <label htmlFor={emailId}>Email</label>
      <input
        id={emailId}
        // Controlled value seeded from server props; correct on first paint.
        value={email}
        onChange={(e) => {
          touched.current = true;
          setEmail(e.target.value);
        }}
      />

      <label htmlFor={nameId}>Name</label>
      <input
        id={nameId}
        // defaultValue makes this input UNCONTROLLED: the server sets the
        // initial DOM value and React never re-asserts it, sidestepping the
        // controlled-value mismatch class entirely for fields you don't need
        // to read reactively.
        defaultValue={initialName}
      />

      {/* Client-only affordance rendered ONLY after hydration, so it never
          exists during the first render and cannot cause a mismatch. */}
      {hydrated && <p aria-live="polite">Draft autosaves locally.</p>}
    </form>
  );
}

Step-by-step walkthrough

  1. Render the first client paint from server data. Seed useState (or defaultValue) from props the server also rendered from. The server and the first client render then produce identical HTML, satisfying the hydration contract. See controlled vs uncontrolled forms for choosing which fields even need controlled state — uncontrolled defaultValue fields cannot mismatch on value.

  2. Generate ids with useId. Feed the same value to the input id and the label htmlFor. useId is deterministic across the server and client passes; counters and Math.random are not.

  3. Defer browser-only reads to useEffect. Anything from localStorage, window, Date, or navigator is read after hydration commits and applied via setState, producing a normal follow-up render rather than a mismatch.

  4. Gate client-only UI behind a hydrated flag. Elements that should not exist server-side render only once hydrated is true, so they never participate in the first render.

  5. Verify across both entry paths — a hard reload (full SSR + hydration) and a client-side navigation into the route (no hydration, but state must still initialize correctly).


Failure modes and fixes

1. Seeding a controlled input from localStorage during render

// BROKEN: server cannot read localStorage, so its HTML has the default while
// the client's first render has the draft → guaranteed value mismatch.
const [email, setEmail] = useState(
  () => window.localStorage.getItem("draft") ?? initialEmail
);

Fix by seeding from the server value and applying the draft in useEffect, exactly as the component above does.

2. Non-deterministic ids

// BROKEN: a new random id on each render pass; label htmlFor never matches.
const id = `email-${Math.random()}`;
// FIX: deterministic and identical on both sides.
const id = useId();

3. suppressHydrationWarning on a whole field or form

suppressHydrationWarning silences the warning for one element and its text content only — it does not reconcile a controlled input’s value, and on a subtree it hides real divergence. It is correct on a leaf like a rendered timestamp:

// ACCEPTABLE: a single text node that legitimately differs by design.
<time suppressHydrationWarning>{new Date().toLocaleTimeString()}</time>

It is wrong on <form suppressHydrationWarning> or an <input> — remove it and fix the underlying value or id divergence.

4. Locale- or timezone-dependent formatting in a field default

Formatting a date or number with the machine locale differs between a server in UTC and a client in the user’s zone.

// FIX: format on the server, pass the finished string as a prop, and render
// that string verbatim on both sides. Do reactive re-formatting in useEffect.
<input defaultValue={props.formattedDate} />

5. Branching on typeof window during render

// BROKEN: the branch differs between server (undefined) and client (object).
const initial = typeof window !== "undefined" ? readClient() : serverDefault;

Render serverDefault unconditionally on the first pass and move readClient() into useEffect. The typeof window guard belongs in effects and event handlers, never in the render body of a form field.


Verification checklist


Frequently Asked Questions

What actually causes a hydration mismatch in a form?

The server renders HTML from one set of values and the client’s first render produces different HTML, so React discards the server markup for that subtree. In forms the usual causes are reading localStorage, Date.now, or window during the initial render, generating non-deterministic ids, or seeding a controlled input from browser-only state the server could not know.

Is suppressHydrationWarning a valid fix?

Only for a single leaf element whose content is legitimately allowed to differ, such as a timestamp text node. It silences the warning for that one element and its direct text, not its descendants, and it does not repair a controlled input value. Wrapping a form or field in it hides real bugs and can leave the input showing stale server text after hydration.

How do I seed a form field from localStorage without a mismatch?

Render the server default first, then read localStorage inside a useEffect that runs after hydration and set the value there. The first client render matches the server, and the stored draft is applied on the next commit. Reading localStorage during render guarantees a mismatch because the server has no access to it.

Why do my label htmlFor and input id mismatch under the App Router?

Hand-rolled ids from a counter or Math.random differ between the server and client render passes. Use React’s useId, which produces a deterministic, tree-position-based id that is identical on both sides, and feed the same value to the input id and the label htmlFor.


Related

Hydration Sync for SSR Forms