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

- Source: https://zerocaptcha.io/blog/cloudflare-turnstile-react-vue-nextjs
- Published: 2026-10-01
- Author: ZeroCaptcha Engineering

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:

```jsx
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:

```ts
// 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](https://zerocaptcha.io/blog/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.

```js
// 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](https://zerocaptcha.io/guides/find-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:

```js
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](https://zerocaptcha.io/cloudflare-turnstile-solver/playwright)
page.

## Sources

- [Cloudflare Turnstile: community resources](https://developers.cloudflare.com/turnstile/community-resources/)
  (checked 1 October 2026).
- [Cloudflare Turnstile: embed the widget](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/)
  and [widget configurations](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/)
  (checked 1 October 2026).
- [Cloudflare Turnstile: server-side validation](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)
  (checked 1 October 2026).
- [The @marsidev React library's documentation](https://docs.page/marsidev/react-turnstile/props) (checked
  1 October 2026).
- [The Nuxt module for Cloudflare Turnstile](https://github.com/nuxt-modules/turnstile) (checked 1 October 2026).
- [Next.js: Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers), version
  16.3.8 of the docs (checked 1 October 2026).
- [Playwright: BrowserContext](https://playwright.dev/docs/api/class-browsercontext#browser-context-request),
  for `context.request` (checked 1 October 2026).

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