How to Add Cloudflare Turnstile to Next.js 14

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 Cloudflare Turnstile Widget

Signup for Cloudflare Turnstile

Create a free account for Cloudflare Turnstile or sign in if you already have one.

Add a New Cloudflare Turnstile Site

Click "Add Site" to create a new widget.

Add Domains to Cloudflare Turnstile

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.

Copy Cloudflare Turnstile Keys

Lastly, copy your Cloudflare Turnstile site key and secret key.

We'll add these to our .env.local in our NextJS app like so:

.env.local
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•••••••••••••••••••

Next.js Client-Side Turnstile Integration

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:

src/components/Form.tsx
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.

src/components/Form.tsx
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.

Creating the Next.js Server Action

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:

src/app/actions.ts
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.

Note

If 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.

Render Form State

Back in our Form component, let's render a success and error message:

src/components/Form.tsx
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.

Force Turnstile Re-render Upon Routing

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:

src/components/Form.tsx
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.

Wrapping Up

To summarize, here's the steps needed to add Cloudflare Turnstile to NextJS 14 using server actions:

  1. Create form component and set form action to server action function
  2. In server action, destructure form data and send request to Cloudflare endpoint to validate Turnstile CAPTCHA attempt
  3. Render state using useFormState hook
  4. (Optional) Force re-render of Turnstile widget on page changes

Hope this helps you protect your forms using Turnstile. Let me know if you have any improvements to this setup or questions.

Ryan Chiang

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:

2024

2023

© 2023 Ryan Chiang|ryanschiang.com