Updated
—
6 min read
I use Cloudflare Turnstile on this very site. It's been a helpful tool to avoid bot form submissions.
Best of all, it is free with unlimited volume.
Here's a quick tutorial on integrating Cloudflare Turnstile with Next.js. While the process is simple, there are some nuances which are important to note which I've discovered over time.
Create a free account for Cloudflare Turnstile or sign in if you already have one.
Click "Add Site" to create a new widget.
In the widget setup, add your domains and choose "Managed" mode, which is the only widget mode available on the free plan.
Make sure to add localhost
to your domains for development purposes.
Lastly, copy your Cloudflare Turnstile site key and secret key.
We'll add these to our .env.local
in our NextJS app like so:
bash# Public site key exposed in the frontend
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAA•••••••••••••••••••
# Secret key, keep secure and never expose in the frontend
NEXT_PRIVATE_TURNSTILE_SECRET_KEY=0x4AAAAA•••••••••••••••••••
As shown in the Cloudflare docs, the clientside integration with the Turnstile widget is straightforward.
Let's make a form component that utilizes the Turnstile widget:
jsx"use client";
import React from "react";
import Script from "next/script";
export const Form: React.FC<{}> = () => {
return (
<form>
<input
type="email"
name="email"
id="email"
placeholder="Enter your email"
/>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></Script>
<div
className="cf-turnstile"
data-sitekey={
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
}
data-callback="javascriptCallback"
></div>
</form>
);
};
To add Cloudflare Turnstile, simply include the provided script and create a div
with the site key and cf-turnstile
className where the widget will render.
Next, we'll use NextJS Server Actions to handle validating the CAPTCHA attempt and processing our form.
Server actions are server-side functions that provide an alternative to NextJS API routes, specifically for handling form submissions.
jsxexport type FormState = {
success: boolean;
error: boolean;
message: string;
}
const initialState: FormState = {
success: false,
error: false;
message: "",
}
export const Form: React.FC<{}> = () => {
const [state, formAction] = useFormState(
submitForm, // this will be the server action we will create
initialState
);
return (
<form action={formAction}>
// ...
</form>
);
}
We are using the useFormState
hook to subscribe to the form state and later render success/error messages upon form submission.
Now we'll create the server-side logic to handle Cloudflare Turnstile validation.
Create a file actions.ts
at the root of the app directory:
jsximport { FormState } from "@/components/Form";
import { headers } from "next/headers";
export async function subscribeToNewsletter(
prevState: FormState,
formData: FormData
) {
// Destructure form data
const email = formData.get("email") as string;
const cfTurnstileResponse = formData.get(
"cf-turnstile-response"
) as string;
// If using a reverse proxy, ensure the X-Real-IP header is enabled to accurately capture the client's original IP address.
const ip = headers().get("x-real-ip");
// Create form data for Turnstile verification
const verifyFormData = new FormData();
verifyFormData.append(
"secret",
process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY
);
verifyFormData.append("response", String(cfTurnstileResponse));
verifyFormData.append("remoteip", String(ip));
const url =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
try {
// Verify Turnstile using Cloudflare endpoint
const result = await fetch(url, {
body: verifyFormData,
method: "POST",
});
const outcome = await result.json();
if (!outcome.success) {
// Turnstile failed
return {
success: false,
error: true,
message: "Invalid CAPTCHA"
};
}
// Turnstile success
return {
success: true,
error: false,
message: "",
}
} catch (err) {
// Request failed
return {
success: false,
error: true,
message: "Unable to verify CAPTCHA"
}
}
}
Our server action destructures the form data, prepares and sends a request to Cloudflare's endpoint, and handles the Turnstile verification response.
NoteIf using a reverse proxy to serve your Next.js app, such as with Nginx, make sure to enable the X-Real-IP header in your proxy settings, e.g. "proxy_set_header X-Real-IP $remote_addr;" in your server block.
Back in our Form
component, let's render a success and error message:
jsxexport const Form: React.FC<{}> = () => {
const [state, formAction] = useFormState(
submitForm, // this will be the server action we will create
initialState
);
return (
<form action={formAction}>
{state.success === true && (
<div>Success! Thanks for your response</div>
)}
{state.error === true && <div>{state.message}</div>}
// ...
</form>
);
};
There's one last key step if we plan on using Turnstile on multiple Next.js pages.
We need to force rerender of the Turnstile widget when routing, because of Next.js's hybrid approach to rendering.
Next.js uses client-side routing, even when using SSR. The initial page load is rendered on the server, but after the initial page load Next.js can be have like a Single Page Application (SPA).
This SPA-like behavior can mean that using multiple Turnstile widgets on different pages can create unexpected behavior.
So we need to force re-render of the Turnstile widget on each page route, like so:
jsximport React, { useEffect } from "react";
import { usePathname } from "next/navigation";
export const Form: React.FC<{}> = () => {
const [state, formAction] = useFormState(
submitForm, // this will be the server action we will create
initialState
);
const pathname = usePathname();
useEffect(() => {
const turnstileContainers =
document.querySelectorAll(".cf-turnstile");
turnstileContainers.forEach((turnstileContainer) => {
turnstileContainer.innerHTML = "";
if (window && window.turnstile) {
window.turnstile.render(turnstileContainer, {
sitekey:
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
callback: "javascriptCallback",
});
}
});
}, [pathname]);
return (
<form action={formAction}>
// ...
</form>
);
};
And that's it! Your Turnstile integration with Next.js should be ready to go.
I'm using usePathname
since our Form
component is a client-component and we can no longer use next/router
events for listening to route changes.
To summarize, here's the steps needed to add Cloudflare Turnstile to NextJS 14 using server actions:
useFormState
hookHope this helps you protect your forms using Turnstile. Let me know if you have any improvements to this setup or questions.
Meet the Author
Ryan Chiang
Hello, I'm Ryan. I build things and write about them. This is my blog of my learnings, tutorials, and whatever else I feel like writing about.
See what I'm building →.
Thanks for reading! If you want a heads up when I write a new blog post, you can subscribe below: