11 KiB
React Integration Guide
Best practices for integrating Turnstile with React, Next.js, and modern React patterns
Recommended Package: @marsidev/react-turnstile (Cloudflare-verified)
Package Installation
npm install @marsidev/react-turnstile
# or
pnpm add @marsidev/react-turnstile
# or
yarn add @marsidev/react-turnstile
Current Version: 1.3.1 (September 2025) React Compatibility: React 18+, Next.js 13+, 14+, 15+
Basic Usage
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export function ContactForm() {
const [token, setToken] = useState<string>()
return (
<form>
<input name="email" type="email" required />
<textarea name="message" required />
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
/>
<button disabled={!token}>Submit</button>
</form>
)
}
Props Reference
Required Props
siteKey
Type: string
Description: Your Turnstile sitekey
<Turnstile siteKey="YOUR_SITE_KEY" />
Optional Props
onSuccess
Type: (token: string) => void
Description: Called when challenge succeeds
<Turnstile onSuccess={(token) => console.log(token)} />
onError
Type: (error: string) => void
Description: Called when challenge fails
<Turnstile onError={(error) => console.error(error)} />
onExpire
Type: () => void
Description: Called when token expires (5 min)
<Turnstile onExpire={() => setToken(undefined)} />
onAbort
Type: () => void
Description: Called when challenge is aborted
options
Type: TurnstileOptions
Description: Widget configuration
<Turnstile
siteKey="..."
options={{
theme: 'dark',
size: 'compact',
action: 'login',
}}
/>
Using Refs
Access widget instance for manual control:
import { useRef } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
export function AdvancedForm() {
const turnstileRef = useRef<TurnstileInstance>(null)
function handleReset() {
turnstileRef.current?.reset()
}
function handleRemove() {
turnstileRef.current?.remove()
}
return (
<>
<Turnstile ref={turnstileRef} siteKey="..." />
<button onClick={handleReset}>Reset</button>
<button onClick={handleRemove}>Remove</button>
</>
)
}
Next.js App Router
Client Component
// app/contact/page.tsx
'use client'
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export default function ContactPage() {
const [token, setToken] = useState<string>()
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
message: formData.get('message'),
'cf-turnstile-response': token,
}),
})
if (response.ok) {
alert('Success!')
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
onExpire={() => setToken(undefined)}
/>
<button disabled={!token}>Submit</button>
</form>
)
}
Server Action (Next.js 14+)
'use server'
import { validateTurnstile } from '@/lib/turnstile'
export async function submitContact(formData: FormData) {
const token = formData.get('cf-turnstile-response')?.toString()
if (!token) {
return { error: 'Missing verification' }
}
const result = await validateTurnstile(token, process.env.TURNSTILE_SECRET_KEY!)
if (!result.success) {
return { error: 'Verification failed' }
}
// Process form
return { success: true }
}
Next.js Pages Router
Page Component
// pages/contact.tsx
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export default function ContactPage() {
const [token, setToken] = useState<string>()
return (
<form>
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={setToken}
/>
</form>
)
}
API Route
// pages/api/contact.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { validateTurnstile } from '@/lib/turnstile'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const token = req.body['cf-turnstile-response']
if (!token) {
return res.status(400).json({ error: 'Missing token' })
}
const result = await validateTurnstile(
token,
process.env.TURNSTILE_SECRET_KEY!,
{
remoteip: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress,
}
)
if (!result.success) {
return res.status(401).json({ error: 'Invalid token' })
}
// Process form
res.status(200).json({ success: true })
}
Custom Hook Pattern
// hooks/useTurnstile.ts
import { useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
export function useTurnstile(siteKey: string) {
const [token, setToken] = useState<string>()
const [isReady, setIsReady] = useState(false)
const [error, setError] = useState<string>()
const turnstileRef = useRef<TurnstileInstance>(null)
const reset = () => {
turnstileRef.current?.reset()
setToken(undefined)
setIsReady(false)
setError(undefined)
}
const TurnstileWidget = () => (
<Turnstile
ref={turnstileRef}
siteKey={siteKey}
onSuccess={(token) => {
setToken(token)
setIsReady(true)
setError(undefined)
}}
onError={(err) => {
setError(err)
setIsReady(false)
}}
onExpire={() => {
setToken(undefined)
setIsReady(false)
}}
/>
)
return {
token,
isReady,
error,
reset,
TurnstileWidget,
}
}
// Usage
export function MyForm() {
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile(
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
)
return (
<form>
<TurnstileWidget />
{error && <div>Error: {error}</div>}
<button disabled={!isReady}>Submit</button>
</form>
)
}
Jest Testing
Mock Setup
// jest.setup.ts
import React from 'react'
jest.mock('@marsidev/react-turnstile', () => ({
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
React.useEffect(() => {
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
}, [onSuccess])
return <div data-testid="turnstile-mock" />
},
}))
Component Test
// ContactForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { ContactForm } from './ContactForm'
test('submits form with Turnstile', async () => {
render(<ContactForm />)
const submitButton = screen.getByRole('button', { name: 'Submit' })
// Turnstile auto-solves (mocked)
await waitFor(() => {
expect(submitButton).not.toBeDisabled()
})
fireEvent.click(submitButton)
expect(await screen.findByText('Success')).toBeInTheDocument()
})
Environment-Aware Sitekey
// lib/turnstile.ts
export function useTurnstileSiteKey() {
// Test/Development: Use dummy key
if (process.env.NODE_ENV !== 'production') {
return '1x00000000000000000000AA'
}
// Production: Use real key
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
}
// Usage
import { useTurnstileSiteKey } from '@/lib/turnstile'
export function MyForm() {
const siteKey = useTurnstileSiteKey()
return <Turnstile siteKey={siteKey} />
}
Known Issues & Workarounds
Issue #112: Next.js + Jest Compatibility (Oct 2025)
Problem: @marsidev/react-turnstile breaks Jest tests
Source: https://github.com/marsidev/react-turnstile/issues/112
Workaround: Mock the component (see Jest Testing section above)
Issue #113: Blocked Script Execution (Oct 2025)
Problem: Script execution blocked in some environments
Source: https://github.com/marsidev/react-turnstile/issues/113
Workaround:
- Check CSP headers allow
challenges.cloudflare.com - Ensure
api.jsloads from CDN (not proxied)
TypeScript Types
import type {
TurnstileInstance,
TurnstileProps,
TurnstileOptions,
} from '@marsidev/react-turnstile'
// Widget instance methods
interface TurnstileInstance {
reset(): void
remove(): void
execute(): void
getResponse(): string | undefined
isExpired(): boolean
}
// Component props
interface TurnstileProps {
siteKey: string
onSuccess?: (token: string) => void
onError?: (error: string) => void
onExpire?: () => void
onAbort?: () => void
options?: TurnstileOptions
scriptOptions?: {
nonce?: string
defer?: boolean
async?: boolean
}
}
// Widget options
interface TurnstileOptions {
theme?: 'light' | 'dark' | 'auto'
size?: 'normal' | 'compact' | 'flexible'
action?: string
cdata?: string
execution?: 'render' | 'execute'
appearance?: 'always' | 'execute' | 'interaction-only'
retry?: 'auto' | 'never'
'retry-interval'?: number
}
Performance Optimization
Lazy Loading
import { lazy, Suspense } from 'react'
const Turnstile = lazy(() =>
import('@marsidev/react-turnstile').then(mod => ({ default: mod.Turnstile }))
)
export function LazyTurnstileForm() {
return (
<form>
<Suspense fallback={<div>Loading verification...</div>}>
<Turnstile siteKey="..." />
</Suspense>
</form>
)
}
Conditional Rendering
Only render Turnstile when needed:
export function ConditionalForm() {
const [showTurnstile, setShowTurnstile] = useState(false)
return (
<form>
<input onChange={() => setShowTurnstile(true)} />
{showTurnstile && <Turnstile siteKey="..." />}
</form>
)
}
Best Practices
✅ Use environment variables for sitekeys
✅ Mock in tests using Jest setup file
✅ Handle expiration with onExpire callback
✅ Disable submit until ready based on token state
✅ Reset after submission for multi-use forms
✅ Use TypeScript for type safety
✅ Lazy load if not immediately needed
❌ Don't hardcode sitekeys in components
❌ Don't skip error handling (onError)
❌ Don't forget server validation (critical!)
❌ Don't use production keys in tests
Last Updated: 2025-10-22 Package Version: @marsidev/react-turnstile@1.3.1 Cloudflare Status: ✅ Officially Recommended