Skip to content

fix(auth): stop Turnstile SSR/client hydration mismatch on /login - #37

Open
tasklyappai wants to merge 2 commits into
mainfrom
fix/turnstile-ssr-hydration
Open

fix(auth): stop Turnstile SSR/client hydration mismatch on /login#37
tasklyappai wants to merge 2 commits into
mainfrom
fix/turnstile-ssr-hydration

Conversation

@tasklyappai

Copy link
Copy Markdown
Contributor

Problem

/login threw a React hydration mismatch that regenerated the entire login form tree on the client. Root cause is in components/security/Turnstile.tsx:

const isLocalhost = typeof window !== 'undefined' && /.../.test(window.location.hostname);
...
if (!SITE_KEY || isLocalhost) return null;
  • Server (no window) → isLocalhost = false → renders the widget container <div class="flex flex-col items-center gap-2">.
  • Client on localhostisLocalhost = true → returns null.

Server rendered the div, client rendered nothing → mismatch. It only surfaced once a NEXT_PUBLIC_TURNSTILE_SITE_KEY was present in the dev env. This is exactly the "first client paint must match SSR" rule in CLAUDE.md.

Fix

  • isLocalhost is now useState(false), so SSR and the first client paint render the identical container. A post-mount effect flips it to true on localhost (and fires the localhost-dev-bypass token) — a normal post-hydration update, not a mismatch.
  • The real-challenge skip inside the effects reads window directly via an isLocal() helper, so localhost dev still never kicks off the doomed Cloudflare 110200 challenge.

Production behaviour is unchanged (non-localhost renders the widget on both server and client, as before).

Verification

  • npm run typecheck — clean
  • 62 Turnstile tests pass (turnstile.test.ts + turnstile-widget.test.tsx)
  • Real-browser load of /loginno hydration error in the dev-server log (vs. multiple [report-error] [client] Hydration failed lines before the fix)

🤖 Generated with Claude Code

tasklyappai and others added 2 commits July 22, 2026 23:24
isLocalhost was derived synchronously from window.location.hostname, so
the server (no window) computed false and rendered the widget container
while the client on localhost computed true and returned null — a
hydration mismatch that regenerated the login form tree. Surfaced once a
NEXT_PUBLIC_TURNSTILE_SITE_KEY was present in the dev env.

Make it SSR-safe: isLocalhost is now useState(false) so SSR and the first
client paint render the identical container; a post-mount effect flips it
(and fires the localhost-dev-bypass token). The real-challenge skip inside
the effects reads window directly via isLocal(), so localhost dev still
never kicks off the doomed 110200 Cloudflare challenge.

Verified: typecheck, 62 Turnstile tests, and a real-browser /login load
with no hydration error in the dev log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Next 16 has no `clean` subcommand, so `next clean` read "clean" as a
project directory and errored ("no such directory: .../clean") — the
documented cache-clear command never worked. Point it at `rm -rf .next`,
which is what CLAUDE.md says it does. Handy right here: the app/on →
app/[province] route rename leaves a stale .next/types until cleared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant