Prerendering your React app is great for SEO. It is less great when it causes your bot protection widget to appear in duplicate on every page load.
After we set up Playwright-based prerendering for our Vite sites, Cloudflare Turnstile started rendering twice on pages that had it. You would load the page and see two identical "Verify you are human" boxes stacked on top of each other. Only one of them actually worked.
The cause is pretty simple once you understand what is happening. Prerendering visits each page with a real browser, so the Turnstile widget renders and gets injected into the page during prerendering. That rendered state gets saved into the static HTML. When a real user loads the page, Turnstile renders again via the normal React lifecycle, but the prerendered version is also sitting there in the HTML. Two widgets.
There are a few ways to fix this depending on your setup.
Option 1: Skip Turnstile during prerendering
In your prerender script, you can set a flag in localStorage or as a URL parameter that tells the React app to skip rendering Turnstile:
// In prerender.mjs, before navigating
await page.evaluate(() => {
localStorage.setItem("skip_turnstile", "true");
});
// In your Turnstile component
const skipTurnstile = localStorage.getItem("skip_turnstile") === "true";
if (skipTurnstile) return null;This works but requires the prerendered HTML to not include the Turnstile markup at all, which means you need to make sure the localStorage check happens before the widget renders.
Option 2: Mount only once with a ref guard
A simpler approach is using a ref to track whether Turnstile has already been mounted:
import { useRef, useEffect } from "react";
export function TurnstileWidget() {
const containerRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(false);
useEffect(() => {
if (mountedRef.current) return;
if (!containerRef.current) return;
// Clear any prerendered content first
containerRef.current.innerHTML = "";
mountedRef.current = true;
window.turnstile?.render(containerRef.current, {
sitekey: import.meta.env.VITE_TURNSTILE_SITEKEY,
});
}, []);
return <div ref={containerRef} />;
}The key line is containerRef.current.innerHTML = "". This clears whatever the prerenderer injected before mounting fresh. It runs on the client only (inside useEffect), so the prerendered HTML gets wiped and replaced with a clean widget on first hydration.
Option 3: Use the official React Turnstile package
The @marsidev/react-turnstile package handles this correctly out of the box. It uses a cleanup function that removes any existing widget before mounting:
import { Turnstile } from "@marsidev/react-turnstile";
export function VerifySection() {
return (
<Turnstile
siteKey={import.meta.env.VITE_TURNSTILE_SITEKEY}
onSuccess={(token) => handleVerify(token)}
/>
);
}We ended up going with Option 2 since we already had a custom implementation and the fix was small. The innerHTML clear approach also works if you are loading Turnstile via the script tag method rather than the npm package.
After the fix, single widget, works correctly, no more visual duplicates. The prerendering setup otherwise remained the same.