nextjs-ssr
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:
'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>
)
}
Manual Script Injection (Recommended)
For better performance and to avoid hydration issues, manually inject the script:
'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:
// 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:
'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
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
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:
'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:
// 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:
// app/page.tsx
import { Turnstile } from '@marsidev/react-turnstile'
export default function Page() {
return <Turnstile siteKey="xxx" /> // ❌ No 'use client'
}
Correct:
// 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:
// Script loads after component renders
<Turnstile siteKey="xxx" />
Correct:
// 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
| Strategy | When to Use |
|---|---|
| beforeInteractive | Load script before page becomes interactive (blocks render) |
| afterInteractive | Load script after page becomes interactive (default, recommended) |
| lazyOnload | Load script during idle time (may cause delay) |
Environment Variables
Store your keys in .env.local:
# .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
- basic-setup skill - Getting started
- token-lifecycle skill - Form integration patterns
- multiple-widgets skill - Multiple widgets on same page