@marsidev/react-turnstile

Cloudflare Turnstile integration for React.

nextjs-ssr

337 linesSource

Integrate Turnstile with Next.js, handle SSR, hydration, and App Router. Activate when building Next.js applications with App Router or Pages Router, or when encountering SSR-related errors.

Next.js and SSR Integration

Integrate Turnstile with Next.js applications, handling server-side rendering, hydration, and script loading optimization.

Important: Client-Side Only

Turnstile is a client-side only library. It must not run during server-side rendering.

App Router

Basic Setup with 'use client'

Always use the 'use client' directive in files using Turnstile:

tsx
'use client'

import { Turnstile } from '@marsidev/react-turnstile'

export default function ContactForm() {
  return (
    <form>
      <input type="email" placeholder="Email" />
      <Turnstile siteKey="YOUR_SITE_KEY" />
      <button type="submit">Submit</button>
    </form>
  )
}

For better performance and to avoid hydration issues, manually inject the script:

tsx
'use client'

import {
  Turnstile,
  SCRIPT_URL,
  DEFAULT_SCRIPT_ID
} from '@marsidev/react-turnstile'
import Script from 'next/script'

export default function ContactForm() {
  return (
    <>
      <Script
        id={DEFAULT_SCRIPT_ID}
        src={SCRIPT_URL}
        strategy="afterInteractive"
      />

      <form>
        <input type="email" placeholder="Email" />

        <Turnstile
          siteKey="YOUR_SITE_KEY"
          injectScript={false}
        />

        <button type="submit">Submit</button>
      </form>
    </>
  )
}

Layout-Level Script (For Multiple Pages)

If you use Turnstile across multiple pages, add the script in your root layout:

tsx
// app/layout.tsx
import { SCRIPT_URL, DEFAULT_SCRIPT_ID } from '@marsidev/react-turnstile'
import Script from 'next/script'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Script
          id={DEFAULT_SCRIPT_ID}
          src={SCRIPT_URL}
          strategy="afterInteractive"
        />
        {children}
      </body>
    </html>
  )
}

Then in individual pages:

tsx
'use client'

import { Turnstile } from '@marsidev/react-turnstile'

export default function Page() {
  return (
    <Turnstile
      siteKey="YOUR_SITE_KEY"
      injectScript={false} // Script already loaded in layout
    />
  )
}

Pages Router

Basic Setup

tsx
import { Turnstile } from '@marsidev/react-turnstile'

export default function ContactPage() {
  return (
    <form>
      <input type="email" placeholder="Email" />
      <Turnstile siteKey="YOUR_SITE_KEY" />
      <button type="submit">Submit</button>
    </form>
  )
}

With Manual Script Injection

tsx
import {
  Turnstile,
  SCRIPT_URL,
  DEFAULT_SCRIPT_ID
} from '@marsidev/react-turnstile'
import Head from 'next/head'

export default function ContactPage() {
  return (
    <>
      <Head>
        <script
          id={DEFAULT_SCRIPT_ID}
          src={SCRIPT_URL}
          async
          defer
        />
      </Head>

      <form>
        <input type="email" placeholder="Email" />
        <Turnstile
          siteKey="YOUR_SITE_KEY"
          injectScript={false}
        />
        <button type="submit">Submit</button>
      </form>
    </>
  )
}

Server Actions (App Router)

When using Server Actions, validate the token on the server:

tsx
'use client'

import { Turnstile } from '@marsidev/react-turnstile'
import { useRef } from 'react'
import type { TurnstileInstance } from '@marsidev/react-turnstile'

export default function ContactForm() {
  const ref = useRef<TurnstileInstance>(null)

  async function handleSubmit(formData: FormData) {
    const token = ref.current?.getResponse()

    if (!token) {
      alert('Please complete the CAPTCHA')
      return
    }

    // Call Server Action with token
    await submitForm(formData, token)

    // Reset for next submission
    ref.current?.reset()
  }

  return (
    <form action={handleSubmit}>
      <input type="email" name="email" placeholder="Email" />
      <Turnstile ref={ref} siteKey="YOUR_SITE_KEY" />
      <button type="submit">Submit</button>
    </form>
  )
}

Server action:

tsx
// app/actions.ts
'use server'

export async function submitForm(formData: FormData, token: string) {
  // Validate token with Cloudflare
  const verification = await fetch(
    'https://challenges.cloudflare.com/turnstile/v0/siteverify',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        secret: process.env.TURNSTILE_SECRET_KEY!,
        response: token,
      }),
    }
  )

  const result = await verification.json()

  if (!result.success) {
    throw new Error('CAPTCHA validation failed')
  }

  // Process form...
}

Common Mistakes

❌ Hydration Mismatch (Missing 'use client')

Error: Hydration failed because the initial UI does not match what was rendered on the server

Wrong:

tsx
// app/page.tsx
import { Turnstile } from '@marsidev/react-turnstile'

export default function Page() {
  return <Turnstile siteKey="xxx" /> // ❌ No 'use client'
}

Correct:

tsx
// app/page.tsx
'use client' // ✅ Required!

import { Turnstile } from '@marsidev/react-turnstile'

export default function Page() {
  return <Turnstile siteKey="xxx" />
}

❌ Window is not defined

Error: window is not defined

Cause: Using Turnstile in a Server Component or during SSR.

Solution: Always use 'use client' directive.

❌ Script Loading Race Condition

Error: Widget doesn't render or shows loading indefinitely.

Wrong:

tsx
// Script loads after component renders
<Turnstile siteKey="xxx" />

Correct:

tsx
// Use next/script with proper strategy
<Script
  id={DEFAULT_SCRIPT_ID}
  src={SCRIPT_URL}
  strategy="beforeInteractive" // or "afterInteractive"
/>
<Turnstile siteKey="xxx" injectScript={false} />

Script Loading Strategies

StrategyWhen to Use
beforeInteractiveLoad script before page becomes interactive (blocks render)
afterInteractiveLoad script after page becomes interactive (default, recommended)
lazyOnloadLoad script during idle time (may cause delay)

Environment Variables

Store your keys in .env.local:

sh
# .env.local
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x0000000000000000000000000000000
TURNSTILE_SECRET_KEY=0x0000000000000000000000000000000000000000000

Note: Only prefix with NEXT_PUBLIC_ for the site key (client-side). Keep the secret key server-side only.

See Also