February 15, 2025
React Hook Form with CAPTCHA
#Overview
While building the www.mncivictech.org
website, one of my goals was to directly
integrate the Contact Us form (along with a few other forms) directly with Slack. This seemed optimal in
comparison to trying to configure email alerts or something else.
#CAPTCHA
So, while building these forms, I was thinking about possibly securing the forms with a CAPTCHA. If unfamiliar, here is a good resource on what a CAPTCHA is.
For now, I decided on Cloudflare Turnstile, but the general premise this post outlines should not vary too significantly if using a different Bot detection tool.
On a side note, the below tweet gets me excited and maybe what will come in the future (and maybe justifies a re-write for all of this :) )
Vercel’s embeddable CAPTCHA is coming. It will feel like a native extension of your product and adapt to your brand & design.
At the highest of levels, Turnstile will generate a client-side token which needs to be verified on the server. This interaction allows for Cloudflare to confirm real web visitors (users) and deny unwanted visitors (bots).
#React Hook Form
After a lot of research over my career and dealing with a various amount of forms and libraries, I have (for now) settled on using React Hook Form (RHF) for maintaining a form's context, errors, and validation. Personally, now that I've used it a bit - I like it a lot and will keep using it.
However, how do you integrate RFH with Cloudflare Turnstile? The code below isn't novel compared to some of the snippets online or in the documentation, but when trying to build this, I found it was a little more complicated than the examples I was seeing.
#Client Side
There are two files at play here, so let's break it down.
First, the actual React Hook Form code. I won't talk in too much detail about how the React Hook Form works, as I think that is less relevant to this piece of writing.
#Form
In terms of Turnstile, a few things to look at:
- There is a
ref
which is used to render theturnstile
widget - There is a
useTurnstile
hook which provides an input to update form state (more on this later) - There is a dedicated
FormField
for the Turnstile
"use client";
// dependencies
import Script from "next/script";
import { useRef, useTransition } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
// form
import { contactFormAction } from "@/app/about/contact-form-action";
import useTurnstile from "@/hooks/useTurnstile";
// shared components
import { Button } from "@/ui/Button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/ui/Form";
import { Input } from "@/ui/Input";
import { Textarea } from "@/ui/Textarea";
export const contactFormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
message: z.string().min(1, { message: "Invalid message" }),
turnstileToken: z
.string()
.min(1, { message: "You must verify you're human" }),
});
export default function ContactForm() {
const ref = useRef<HTMLDivElement>(null);
const [isPending, startTransition] = useTransition();
const form = useForm<z.infer<typeof contactFormSchema>>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
email: "",
message: "",
turnstileToken: "",
},
});
// cloudflare turnstile hook (provides way to update form state)
const { buildTurnstile, resetTurnstile } = useTurnstile(ref, (token: string) =>
form.setValue("turnstileToken", token),
);
const onSubmit = form.handleSubmit((data) => {
startTransition(async () => {
try {
await contactFormAction(data);
alert(
"Thank you for reaching out! Your message has been successfully submitted. We will get back to you as soon as possible.",
);
form.reset();
} catch (error) {
alert(
"There was an error submitting your message. Please try again later.",
);
resetTurnstile(ref);
}
});
});
return (
<div className="mx-auto my-8">
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
onReady={buildTurnstile}
/>
<Form {...form}>
<form onSubmit={onSubmit} className="max-w-2xl space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email *</FormLabel>
<FormControl>
<Input
type="email"
placeholder="john@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message *</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Custom Form Element for Cloudflare Turnstile */}
<FormField
control={form.control}
name="turnstileToken"
render={() => (
<FormItem>
<FormControl>
<div ref={ref} data-sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isPending}
variant="green"
className="w-full md:w-36"
>
Submit
</Button>
</form>
</Form>
</div>
);
}
#Hook
Secondly, here is the magic of the Turnstile encapsulated in a React Hook. This hook handles loading, rendering, and teardown of the Turnstile widget.
It is worth noting that this hook accepts both a ref
which is what Turnstile renders within and a updateToken
function which helps us update the form state whenever the Turnstile state changes. This ability to update the token
whenever the state changes is quite helpful for integrating the token into React Hook Form's Zod validation and
handleSubmit
function.
import { type RefObject, useEffect, useState } from "react";
declare const turnstile: {
render: (
element: string | HTMLElement,
options: {
sitekey: string;
language: string;
execution: "render" | "execute";
callback: (token: string) => void;
"expired-callback": (ref: RefObject<HTMLDivElement>) => void;
},
) => string;
remove: (widgetId: string) => void;
execute: (element: string | HTMLElement) => void;
reset: (element: string | HTMLElement) => void;
};
export default function useTurnstile(
ref: RefObject<HTMLDivElement>,
updateToken: (token: string) => void,
) {
const [turnstileWidgetId, setTurnstileWidgetId] = useState("");
// mount and render turnstile widget
function buildTurnstile() {
if (ref.current == null) {
return;
}
// render widget inside the ref (in our case, div inside a form field)
const widgetId = turnstile.render(ref.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
language: "en",
execution: "render",
callback: (token: string) => {
updateToken(token); // update token
},
"expired-callback": (ref: RefObject<HTMLDivElement>) => {
updateToken(""); // reset token
if (ref.current == null) {
return;
}
turnstile.reset(ref.current);
turnstile.execute(ref.current);
},
});
setTurnstileWidgetId(widgetId);
}
// if validation failed, we can reset the turnstile widget
function resetTurnstile(ref: RefObject<HTMLDivElement>) {
if (ref.current == null) {
return;
}
turnstile.reset(ref.current);
turnstile.execute(ref.current);
}
// remove turnstile widget when component unmounts
useEffect(() => {
return () => {
if (turnstileWidgetId) {
turnstile.remove(turnstileWidgetId);
}
};
}, [turnstileWidgetId]);
return {
buildTurnstile,
resetTurnstile,
};
}
#Server Side
Now that we have all of that figured out, the only thing left is to validate the turnstileToken
on our server to ensure
the user (or bot 🤖) messed with the client-side token.
"use server";
import type { z } from "zod";
import type { contactFormSchema } from "@/app/about/contact-form";
import { verifyTurnstile } from "@/utils/turnstile";
export async function contactFormAction(
formData: z.infer<typeof contactFormSchema>,
) {
if (!(await verifyTurnstile(formData.turnstileToken))) {
throw new Error("Invalid Turnstile token");
}
// continue the rest of your logic after validation
}
This verifyTurnstile
function is very straightforward ... the
documentation does a great job outlining
all of this as well. However, simply put, we need to make a POST
to Cloudflare's API that effectively validates the
turnstileToken
with our TURNSTILE_SECRET_KEY
.
export async function verifyTurnstile(token: string): Promise<boolean> {
const secretKey = process.env.TURNSTILE_SECRET_KEY;
if (secretKey == null) {
console.error("Missing TURNSTILE_SECRET_KEY in environment variables");
return false;
}
try {
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: secretKey,
response: token,
}),
},
);
const data = await response.json();
return data.success;
} catch (error) {
console.error("Error verifying Turnstile token:", error);
return false;
}
}
#Complete 🎉
And there you have it — a quick integration of a CAPTCHA with React Hook Form and Next.js!
I found this to be a fun little challenge, but hopefully if you are running into issues, this gave you a little bit more of an idea on how to solve your problem!