with one click
token-lifecycle
// Handle token generation, expiration, validation workflow, and form integration. Activate when implementing form submission with CAPTCHA, handling token expiration, or integrating with server-side validation.
// Handle token generation, expiration, validation workflow, and form integration. Activate when implementing form submission with CAPTCHA, handling token expiration, or integrating with server-side validation.
Install and render your first Turnstile widget with required configuration. Activate this skill when starting a new project with react-turnstile or when implementing basic CAPTCHA protection for the first time.
Handle multiple Turnstile widgets on the same page without conflicts. Activate when adding more than one CAPTCHA to a page, or when widgets interfere with each other.
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.
Customize widget appearance, theme, execution mode, and behavior. Activate when styling the widget, changing when/how tokens are generated, or integrating with specific UX flows like multi-step forms.
| name | token-lifecycle |
| description | Handle token generation, expiration, validation workflow, and form integration. Activate when implementing form submission with CAPTCHA, handling token expiration, or integrating with server-side validation. |
| triggers | ["turnstile token expired","turnstile getResponse","turnstile reset","turnstile form submit","turnstile onSuccess","turnstile onExpire","validate turnstile token","turnstile server validation"] |
| category | lifecycle |
| metadata | {"library":"@marsidev/react-turnstile","library_version":"1.4.2","framework":"React"} |
Handle token generation, expiration, validation workflow, and form integration.
Turnstile tokens are single-use and expire after a timeout (typically 5 minutes). Once validated by your server, they cannot be used again.
Get the token when the user completes the challenge:
import { Turnstile } from '@marsidev/react-turnstile'
import { useState } from 'react'
export default function ContactForm() {
const [token, setToken] = useState<string | null>(null)
return (
<form>
<input type="email" placeholder="Email" />
<Turnstile
siteKey="YOUR_SITE_KEY"
onSuccess={(token) => setToken(token)}
/>
<button type="submit" disabled={!token}>
Submit
</button>
</form>
)
}
Get the token imperatively at submission time:
import { Turnstile } from '@marsidev/react-turnstile'
import type { TurnstileInstance } from '@marsidev/react-turnstile'
import { useRef } from 'react'
export default function ContactForm() {
const ref = useRef<TurnstileInstance>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
// Get token at the moment of submission
const token = ref.current?.getResponse()
if (!token) {
alert('Please complete the CAPTCHA')
return
}
await submitForm(token)
}
return (
<form onSubmit={handleSubmit}>
<input type="email" placeholder="Email" />
<Turnstile ref={ref} siteKey="YOUR_SITE_KEY" />
<button type="submit">Submit</button>
</form>
)
}
The widget automatically adds a hidden input. Access it via FormData:
export default function ContactForm() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const token = formData.get('cf-turnstile-response')
if (!token) {
alert('Please complete the CAPTCHA')
return
}
await submitForm(token)
}
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="Email" />
<Turnstile siteKey="YOUR_SITE_KEY" />
<button type="submit">Submit</button>
</form>
)
}
Best practice: Get token at submission time, validate server-side, reset widget:
'use client'
import { Turnstile } from '@marsidev/react-turnstile'
import type { TurnstileInstance } from '@marsidev/react-turnstile'
import { useRef, useState } from 'react'
export default function ContactForm() {
const ref = useRef<TurnstileInstance>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setError(null)
const token = ref.current?.getResponse()
if (!token) {
setError('Please complete the CAPTCHA')
return
}
setIsSubmitting(true)
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, /* form data */ })
})
if (!response.ok) {
throw new Error('Submission failed')
}
// Success! Reset widget for potential re-submission
ref.current?.reset()
// Clear form, show success message, etc.
} catch (err) {
setError('Failed to submit. Please try again.')
// Reset widget on error too (token may be expired/used)
ref.current?.reset()
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
{error && <div className="error">{error}</div>}
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<Turnstile
ref={ref}
siteKey="YOUR_SITE_KEY"
onExpire={() => {
// Optional: handle expiration
console.log('Token expired, please retry')
}}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
)
}
Tokens expire after ~5 minutes. Handle this with onExpire callback:
<Turnstile
ref={ref}
siteKey="YOUR_SITE_KEY"
options={{ refreshExpired: 'manual' }}
onExpire={() => {
// Token expired - inform user or auto-reset
alert('Verification expired. Please try again.')
ref.current?.reset()
}}
/>
Or let it auto-refresh:
<Turnstile
siteKey="YOUR_SITE_KEY"
options={{ refreshExpired: 'auto' }} // Default
/>
Important: The library provides TypeScript types but NO built-in validation. You must implement server-side validation yourself.
// app/api/verify/route.ts
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const { token } = await request.json()
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) {
return NextResponse.json(
{ error: 'CAPTCHA validation failed' },
{ status: 400 }
)
}
return NextResponse.json({ success: true })
}
# .env.local (server-side only)
TURNSTILE_SECRET_KEY=0x0000000000000000000000000000000000000000000
Never expose the secret key client-side!
Control the widget programmatically:
Reset the widget after submission or error:
ref.current?.reset()
Wait for token with timeout (useful for programmatic flows):
try {
const token = await ref.current?.getResponsePromise(30000) // 30s timeout
// Use token...
} catch (error) {
// Timeout or widget error
}
Check if token has expired:
if (ref.current?.isExpired()) {
ref.current?.reset()
}
Advanced: Fully remove and re-render the widget:
ref.current?.remove() // Remove from DOM
ref.current?.render() // Re-render (only if previously removed)
Problem: User takes too long to submit, token expires.
Wrong:
const [token, setToken] = useState<string | null>(null)
<Turnstile onSuccess={setToken} />
// User delays submitting...
<button onClick={() => submit(token)}>Submit</button> // Token expired!
Correct:
// Get token at submission time, not onSuccess
const token = ref.current?.getResponse()
await submit(token)
ref.current?.reset() // Reset for next time
Wrong:
import { validateTurnstile } from '@marsidev/react-turnstile'
// ❌ This doesn't exist!
Correct:
// Implement your own server validation
const response = await fetch('/api/verify', {
method: 'POST',
body: JSON.stringify({ token })
})
Wrong:
useEffect(() => {
// Widget not ready yet!
const token = ref.current?.getResponse() // undefined
}, [])
Correct:
// Wait for onSuccess or user action
<Turnstile
ref={ref}
onSuccess={(token) => {
// Widget is ready
}}
/>
Problem: Token is single-use. After server validation, it's invalid.
Correct:
const result = await validateToken(token)
if (result.success) {
// Process form...
ref.current?.reset() // Reset for potential re-submission
}