# SSR/Hydration Pitfalls for VIN Decode Forms (Server Empty, Client Filled)

VIN lookup forms look simple: an input, a button, a result card. Under SSR (Next.js, Remix, or any React tree that renders on the server first) that simplicity hides a classic mismatch: the **server** often renders an empty controlled input, while the **client** immediately fills it from `localStorage`, a query string, or a pasted clipboard helper. React then warns -- or worse, replaces user-visible state -- because the hydrated markup did not match.

This post covers practical TypeScript patterns for VIN decode forms that stay honest across SSR and hydration: empty on the server by design, filled on the client after mount, without double-fetching NHTSA or flashing the wrong VIN.

## The failure mode

Typical sequence:

1. Server renders `<input value="">` (no `window`, no local storage, no private query you chose not to SSR).
2. HTML reaches the browser.
3. Client bundle runs, reads `?vin=` or a saved VIN, and sets state during the first render.
4. Hydration expects the server HTML to match that first client render -- it does not.
5. You get a hydration warning, a wiped input, or a decode that fires twice.

Related traps: different VIN normalization on server vs client, timezone-dependent timestamps, unstable `key`s, and starting decode both during render and in `useEffect`.

## Rule: server shell, client fill

Treat the SSR HTML as a stable empty shell for private or browser-only VIN sources. Apply client-only initial values in `useEffect` (or an equivalent mount gate), not in the initial `useState` initializer if that initializer reads `window`.

```ts
import { useEffect, useState } from "react";

const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;

export function normalizeVin(raw: string): string {
  return raw
    .trim()
    .toUpperCase()
    .replace(/[\s\-._]/g, "");
}

export function VinDecodeForm(props: {
  initialVinFromQuery?: string | null;
  onDecode: (vin: string) => void;
}) {
  // SSR + first client render: always the same empty string unless you
  // intentionally SSR a public query vin (see below).
  const [vin, setVin] = useState("");
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const fromQuery = props.initialVinFromQuery ?? "";
    const fromStore =
      typeof window !== "undefined"
        ? window.sessionStorage.getItem("lastVin") ?? ""
        : "";
    const candidate = normalizeVin(fromQuery || fromStore);
    if (VIN_RE.test(candidate)) {
      setVin(candidate);
    }
    setReady(true);
  }, [props.initialVinFromQuery]);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const next = normalizeVin(vin);
        if (!VIN_RE.test(next)) return;
        props.onDecode(next);
      }}
    >
      <label htmlFor="vin">VIN</label>
      <input
        id="vin"
        name="vin"
        value={vin}
        autoComplete="off"
        spellCheck={false}
        onChange={(e) => setVin(e.target.value.toUpperCase())}
      />
      <button type="submit" disabled={!ready}>
        Decode
      </button>
    </form>
  );
}
```

`ready` prevents a submit race before the client has applied stored values. The first paint matches SSR; the fill happens after hydration commits.

## When you do SSR the query VIN

Sometimes the VIN is in a public URL (`/lookup?vin=...`) and you want shareable SSR content. Then the server and client must use the **same** normalization:

```ts
export function vinFromSearchParam(
  raw: string | string[] | undefined | null,
): string {
  const s = Array.isArray(raw) ? raw[0] : raw;
  if (!s) return "";
  const v = normalizeVin(s);
  return VIN_RE.test(v) ? v : "";
}

// Server loader / getServerSideProps / page render:
// const initialVin = vinFromSearchParam(url.searchParams.get("vin"));
// Client: useState(initialVin) -- same function, same input.
```

Do **not** SSR a VIN from `localStorage` or cookies you only read on the client. Do not SSR one normalization and hydrate another (for example server strips spaces, client does not).

## Avoid double decode on hydrate

A common bug: the server kicks off a decode for the query VIN, the client hydrates, and `useEffect` decodes again -- two NHTSA calls, two loading flashes.

```ts
export function useDecodeOnce(
  vin: string,
  decode: (vin: string) => Promise<void>,
) {
  const [startedFor, setStartedFor] = useState<string | null>(null);

  useEffect(() => {
    if (!VIN_RE.test(vin)) return;
    if (startedFor === vin) return;
    setStartedFor(vin);
    void decode(vin);
  }, [vin, decode, startedFor]);
}
```

Better: pass server-fetched decode props into the tree and skip the client fetch when the payload for that VIN is already present. Single-flight on the client still helps for remounts, but the cleanest fix is "do not refetch what SSR already provided."

```ts
export type DecodeProps = {
  vin: string;
  prefetched: { vin: string; body: unknown } | null;
};

export function shouldClientFetch(p: DecodeProps): boolean {
  if (!VIN_RE.test(p.vin)) return false;
  if (p.prefetched && p.prefetched.vin === p.vin) return false;
  return true;
}
```

## Timestamps, placeholders, and keys

Hydration mismatches are not only about the input value. Prefer UTC (or post-mount relative times) over `toLocaleString()` during SSR, avoid random placeholders, keep `key`s stable, and share one `vinTail` helper if you mask display.

```ts
export function vinTail(vin: string): string {
  const v = normalizeVin(vin);
  return v.length >= 4 ? v.slice(-4) : v;
}
```

## Testing the contract

Add a small unit test for the pure helpers and a component test that first render equals empty when no SSR vin is passed:

```ts
import assert from "node:assert/strict";

assert.equal(normalizeVin(" 1hgcm82633a004352 "), "1HGCM82633A004352");
assert.equal(vinFromSearchParam("1hgcm82633a004352"), "1HGCM82633A004352");
assert.equal(vinFromSearchParam("too-short"), "");
assert.equal(shouldClientFetch({
  vin: "1HGCM82633A004352",
  prefetched: { vin: "1HGCM82633A004352", body: {} },
}), false);
assert.equal(shouldClientFetch({
  vin: "1HGCM82633A004352",
  prefetched: null,
}), true);
```

In browser tests, assert that session-filled VINs appear only after mount -- not in the SSR HTML snapshot.

## Takeaway

For VIN decode forms, pick an intentional SSR policy: empty shell plus client fill for browser-only sources, or shared normalization when the VIN is truly in the URL. Never set client-only state during the first render, never double-hit NHTSA on hydrate, and keep dates and keys stable. Your free lookup stays calm in the console and honest in the network panel.

I maintain [VIN Lookup](https://free-vin-lookup.com/), a free VIN decode based on NHTSA data.

Originally published on DEV: https://dev.to/vin_lookup_8dbd4710f77e9e/ssrhydration-pitfalls-for-vin-decode-forms-server-empty-client-filled-2hm7

