Form submission retry logic

Turnstile tokens are single-use — once validated on the server, the token is consumed and cannot be reused. This means that if a user submits a form and hits a validation error (e.g. wrong password), they must complete the Turnstile challenge again before resubmitting, which creates unnecessary friction.

A common solution is to allow a few re-submissions with the same token before requiring a new challenge. You can implement this using the useRef API to programmatically reset the widget only when needed.

Adjust MAX_TRIES to control how many total submissions share one validated token. Set it to 1 to validate on every submission.

See Validating a token for the server-side /api/verify route setup.

"use client";
import { useRef, useState } from "react";
import { Turnstile } from "@marsidev/react-turnstile";
import type { TurnstileInstance } from "@marsidev/react-turnstile";

const MAX_TRIES = 5;

export default function LoginForm() {
  const turnstileRef = useRef<TurnstileInstance | null>(null);
  const [triesLeft, setTriesLeft] = useState(0);
  const [captchaToken, setCaptchaToken] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    try {
      if (triesLeft === 0) {
        const res = await fetch("/api/verify", {
          method: "POST",
          body: JSON.stringify({ token: captchaToken }),
          headers: { "content-type": "application/json" },
        });

        const data = await res.json();

        if (!data.success) {
          setError("Captcha validation failed. Please try again.");
          turnstileRef.current?.reset();
          return;
        }

        setTriesLeft(MAX_TRIES - 1);
        setError(null);
      } else {
        setTriesLeft((prev) => prev - 1);
      }

      const res = await fetch("/api/login", {
        method: "POST",
        body: JSON.stringify({
          username: formData.get("username"),
          password: formData.get("password"),
        }),
        headers: { "content-type": "application/json" },
      });

      const data = (await res.json()) as { success: boolean; message?: string };

      if (!data.success) {
        setError(data.message ?? "Login failed. Please try again.");
      }
    } catch {
      setError("An unexpected error occurred. Please try again.");
      turnstileRef.current?.reset();
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="username" placeholder="Username" />
      <input type="password" name="password" placeholder="Password" />
      {error && <p>{error}</p>}
      <Turnstile
        ref={turnstileRef}
        siteKey="1x00000000000000000000AA"
        onSuccess={(token) => setCaptchaToken(token)}
        onExpire={() => {
          setCaptchaToken(null);
          setTriesLeft(0);
        }}
      />
      <button type="submit">Login</button>
    </form>
  );
}

The onExpire callback resets triesLeft to 0 so the next submission triggers a fresh server-side validation with the new token. Always validate on the server — the retry counter is a client-side UX optimization only.