Skip to content

Tutorial

Cloudflare Turnstile in React, Vue and Next.js Forms

How React, Vue, Nuxt and Next.js apps render Cloudflare Turnstile and send its token, how to verify it on the server, and how automation submits these forms.

By 6 min readPublished

In React, Vue and Next.js apps, the Cloudflare Turnstile widget is almost always rendered explicitly, with turnstile.render() or a component that wraps it, and the app sends its token in one of three ways: the hidden cf-turnstile-response field of a real <form>, a key of its own in a JSON body (often token), or a header. The token must then be checked on the server with Cloudflare’s siteverify API, in a Next.js Route Handler or Server Action, or a Nuxt server route. For automation, the rule follows: find out how the app sends the token, then send exactly that, with a fresh token.

This tutorial covers each framework for your own apps, then how an authorized automated client submits such forms. Facts are from the official documentation as checked on 1 October 2026.

Cloudflare Turnstile in React

Cloudflare’s community resources list two React libraries, react-turnstile and @marsidev/react-turnstile, and say: “Cloudflare recommends @marsidev/react-turnstile when rendering Turnstile. We have deployed an implementation of the library and can confirm that it is safe to use and works as expected.” The list is community-made; Cloudflare notes it is “not maintained directly by Cloudflare”.

The component takes a siteKey, calls onSuccess with the token, and has onError, onExpire and onTimeout callbacks, an options object for the widget’s render parameters, and a ref whose reset() gets a new token:

import { Turnstile } from "@marsidev/react-turnstile";
import { useRef, useState } from "react";
export function ContactForm() {
const turnstile = useRef(null);
const [token, setToken] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: form.get("email"), message: form.get("message"), token }),
});
// A token works once, whatever the answer: get a fresh one for the next submission.
setToken("");
turnstile.current?.reset();
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<Turnstile
ref={turnstile}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
options={{ action: "contact" }}
onSuccess={setToken}
onExpire={() => setToken("")}
/>
<button type="submit" disabled={!token}>Send</button>
</form>
);
}

This form sends the token as token in JSON. Had it posted the form natively, the widget’s own hidden field would have gone with it: Cloudflare’s response-field option, true by default, creates the input named cf-turnstile-response.

Next.js: verify the token on the server

A Route Handler is a route.ts file in the app directory that exports a function per HTTP method. This one verifies the token before it accepts the message:

app/api/contact/route.ts
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export async function POST(request: Request) {
const { email, message, token } = await request.json();
if (typeof token !== "string" || token === "") {
return Response.json({ error: "missing Cloudflare Turnstile token" }, { status: 400 });
}
const reply = await fetch(SITEVERIFY, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token }),
}).then((response) => response.json());
if (!reply.success || reply.action !== "contact") {
return Response.json({ error: "verification failed", codes: reply["error-codes"] }, { status: 403 });
}
// The token was genuine, fresh and unused: handle the message.
console.log("contact form from", email, String(message ?? "").length);
return Response.json({ ok: true });
}

Siteverify “accepts both application/x-www-form-urlencoded and application/json requests”, and Cloudflare’s rule is to “Only call the Siteverify API in your backend environment”. Checking action follows its advice to validate the action and hostname when you set them. With a Server Action and a native form, read the token with formData.get("cf-turnstile-response") instead. Every code the reply can carry is in Cloudflare Turnstile siteverify errors.

Cloudflare Turnstile in Vue: a small composable

Cloudflare lists vue-cloudflare-turnstile, cfturnstile-vue3 and vue-turnstile for Vue. You can also call the widget’s API directly: load https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit once, in a script tag placed before your app’s bundle, then render into an element when the component mounts (inside turnstile.ready(), as Cloudflare’s single-page app example does), and remove it when it unmounts.

useTurnstile.js
import { onBeforeUnmount, onMounted, ref } from "vue";
export function useTurnstile(container, sitekey, action) {
const token = ref("");
let widgetId;
onMounted(() => {
window.turnstile.ready(() => {
widgetId = window.turnstile.render(container.value, {
sitekey,
action,
callback: (value) => {
token.value = value;
},
"expired-callback": () => {
token.value = "";
},
});
});
});
onBeforeUnmount(() => {
if (widgetId !== undefined) window.turnstile.remove(widgetId);
});
return {
token,
reset: () => window.turnstile.reset(widgetId),
};
}

Removing the widget on unmount matters in single-page apps: without it, navigating back and forth leaves widgets behind.

Cloudflare Turnstile in Nuxt

Nuxt has a full-stack module, @nuxtjs/turnstile, added with npx nuxi@latest module add turnstile. Its README configures turnstile.siteKey in nuxt.config, reads the secret from NUXT_TURNSTILE_SECRET_KEY, renders <NuxtTurnstile v-model="token" /> in the form, and verifies on the server with its verifyTurnstileToken(token) helper. The README notes that tokens are valid for 300 seconds and that the component renews its token after 250.

How automation submits these forms

For a site you are allowed to automate, the approach is the same whichever framework built it:

  1. Find the sitekey. Explicitly rendered widgets often have no data-sitekey in the HTML; the key is in the app’s JavaScript or its configuration. Find a Cloudflare Turnstile sitekey shows where to look.
  2. Watch one real submission. Submit the form by hand with the browser’s network panel open and note the request: its URL, method, body format, and the name of the field or header that carries the token.
  3. Send the same request with a fresh token, from a session that loaded the page, so its cookies go with it.

Writing into a hidden field does nothing for a React form that keeps the token in state, so the direct request is the reliable path. With Playwright, the browser context’s request API sends it with the context’s cookies:

import { chromium } from "playwright";
const API = process.env.ZEROCAPTCHA_API;
const KEY = process.env.ZEROCAPTCHA_KEY;
const PAGE = "https://app.example.com/contact";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(method, body, headers = {}) {
const response = await fetch(`${API}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify({ clientKey: KEY, ...body }),
});
const reply = await response.json();
if (reply.errorId) throw new Error(`${method}: ${reply.errorCode}`);
return reply;
}
async function solveTurnstile(websiteURL, websiteKey, { action, cdata } = {}) {
// The widget's action and cData go in metadata, only when it sets them.
const task = { type: "TurnstileTaskProxyless", websiteURL, websiteKey, metadata: {} };
if (action) task.metadata.action = action;
if (cdata) task.metadata.cdata = cdata;
// One Idempotency-Key per task: a retried create with it returns the same task.
const { taskId } = await call("createTask", { task }, { "Idempotency-Key": crypto.randomUUID() });
for (let polls = 0; polls < 90; polls += 1) {
await sleep(2_000);
const result = await call("getTaskResult", { taskId });
if (result.status === "ready") return result.solution.token;
}
throw new Error(`task ${taskId}: no token within 180 seconds`);
}
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(PAGE);
// This form's widget sets the action "contact" (options={{ action: "contact" }}) and no cData; pass
// a cdata too when a widget sets one, as data-cdata or turnstile.render()'s cData option.
const token = await solveTurnstile(PAGE, process.env.SITEKEY, { action: "contact" });
const response = await context.request.post("https://app.example.com/api/contact", {
data: { email: "you@example.com", message: "Hello", token },
});
console.log(response.status(), await response.text());
await browser.close();

Send action only when the widget sets one, exactly as it does: the site’s server may compare it. The token is valid for 300 seconds and works once, so solve right before the request. If the whole app sits behind a Cloudflare challenge page instead, a challenge task returns the cf_clearance cookie with the user agent it is bound to. More Playwright examples are on the Cloudflare Turnstile solver for Playwright page.

Sources

The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.

Questions

Which React library does Cloudflare recommend for Turnstile?

@marsidev/react-turnstile. Cloudflare's community resources page says it recommends that library when rendering Turnstile, and that it has deployed an implementation and can confirm it is safe to use and works as expected.

How do I verify a Cloudflare Turnstile token in Next.js?

On the server, in a Route Handler or a Server Action: read the token from the form data or JSON body, post it with your secret key to Cloudflare's siteverify endpoint, and accept the form only if the reply's success is true. Never verify in the browser.

How does automation submit a React or Vue form with Cloudflare Turnstile?

Find out how the app sends the token (the cf-turnstile-response field, a JSON key or a header) by submitting once by hand with the browser's network panel open, then send the same request with a token from a solving API, with the session's cookies.

Read next

This article is part of the Cloudflare Turnstile solver hub. Every task is charged only when a token is ready.

Get an API key