Skip to main content
React Turnstile automatically injects the Cloudflare Turnstile script into your page. This page explains how script injection works, how to customize it, and how to handle it manually for advanced use cases.

Default Behavior

By default, the Turnstile component automatically injects the Cloudflare script when it mounts:
<Turnstile siteKey="your-site-key" />
This single line of code:
  1. Injects the script: https://challenges.cloudflare.com/turnstile/v0/api.js
  2. Waits for it to load
  3. Renders the widget
Single Script Instance: The script is injected only once per page, even if you have multiple Turnstile widgets. All widgets share the same global window.turnstile instance.

How Script Injection Works

The injection logic is defined in utils.ts:22-71:
export const injectTurnstileScript = ({
  render = 'explicit',
  onLoadCallbackName = DEFAULT_ONLOAD_NAME,
  scriptOptions: {
    nonce = '',
    defer = true,
    async = true,
    id = '',
    appendTo,
    onError,
    crossOrigin = ''
  } = {}
}: InjectTurnstileScriptParams) => {
  const scriptId = id || DEFAULT_SCRIPT_ID

  // Prevent duplicate injection
  if (checkElementExistence(scriptId)) {
    return
  }

  const script = document.createElement('script')
  script.id = scriptId
  script.src = `${SCRIPT_URL}?onload=${onLoadCallbackName}&render=${render}`

  // Prevent duplicate script injection with the same src
  if (document.querySelector(`script[src="${script.src}"]`)) {
    return
  }

  script.defer = !!defer
  script.async = !!async

  if (nonce) {
    script.nonce = nonce
  }

  if (crossOrigin) {
    script.crossOrigin = crossOrigin
  }

  if (onError) {
    script.onerror = onError
    delete window[onLoadCallbackName]
  }

  const parentEl = appendTo === 'body' ? document.body : document.getElementsByTagName('head')[0]
  parentEl!.appendChild(script)
}

Key Features

  • Duplicate Prevention: Checks for existing script by ID and src before injection
  • Onload Callback: Uses query parameter ?onload= for load notification
  • Explicit Render: Always uses render=explicit to control rendering via React
  • Async/Defer: Scripts are async and deferred by default for better performance

Customizing Script Options

Use the scriptOptions prop to customize script injection:

ScriptOptions Interface

scriptOptions.nonce
string
default:"undefined"
Custom nonce for the injected script. Required for Content Security Policy (CSP) compliance.
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ nonce: 'your-csp-nonce' }}
/>
scriptOptions.defer
boolean
default:true
Whether to set the injected script as defer.
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ defer: true }}
/>
scriptOptions.async
boolean
default:true
Whether to set the injected script as async.
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ async: true }}
/>
scriptOptions.appendTo
'head' | 'body'
default:"head"
Where to inject the script in the DOM.
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ appendTo: 'body' }}
/>
scriptOptions.id
string
default:"cf-turnstile-script"
Custom ID for the injected script element.
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ id: 'my-custom-script-id' }}
/>
scriptOptions.onLoadCallbackName
string
default:"onloadTurnstileCallback"
Custom name for the global onload callback function.
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ onLoadCallbackName: 'myCustomCallback' }}
/>
scriptOptions.onError
() => void
Callback invoked when the script fails to load (e.g., Cloudflare has an outage).
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{
    onError: () => {
      console.error('Failed to load Turnstile script')
      // Show fallback UI or error message
    }
  }}
/>
scriptOptions.crossOrigin
string
default:"undefined"
Custom crossOrigin attribute for the injected script.
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ crossOrigin: 'anonymous' }}
/>

Content Security Policy (CSP)

When using Content Security Policy, you need to allow the Turnstile script and provide a nonce.

CSP Headers

Add these directives to your CSP policy:
Content-Security-Policy: 
  script-src 'nonce-YOUR_NONCE' https://challenges.cloudflare.com;
  frame-src https://challenges.cloudflare.com;

Using Nonce

Generate a nonce on your server and pass it to the component:
import { Turnstile } from '@marsidev/react-turnstile'

function MyForm({ nonce }: { nonce: string }) {
  return (
    <Turnstile
      siteKey="your-site-key"
      scriptOptions={{ nonce }}
    />
  )
}
Important: The nonce must be:
  • Unique per request
  • Generated server-side
  • Included in both the CSP header and the script tag

Manual Script Injection

For advanced use cases, you can disable automatic injection and load the script yourself.

Disable Automatic Injection

<Turnstile
  siteKey="your-site-key"
  injectScript={false}
/>

Manual Injection Methods

Add the script directly in your HTML:
<!DOCTYPE html>
<html>
  <head>
    <script 
      src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
      async
      defer
    ></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
Then in React:
<Turnstile
  siteKey="your-site-key"
  injectScript={false}
/>
Use Next.js <Script> component:
import Script from 'next/script'
import { Turnstile } from '@marsidev/react-turnstile'

export default function Page() {
  return (
    <>
      <Script
        src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
        strategy="lazyOnload"
      />
      
      <Turnstile
        siteKey="your-site-key"
        injectScript={false}
      />
    </>
  )
}
Create a custom script loader:
import { useEffect, useState } from 'react'

function useLoadTurnstile() {
  const [loaded, setLoaded] = useState(false)
  
  useEffect(() => {
    // Check if already loaded
    if (window.turnstile) {
      setLoaded(true)
      return
    }
    
    // Check if script exists
    const existing = document.getElementById('cf-turnstile-script')
    if (existing) {
      existing.addEventListener('load', () => setLoaded(true))
      return
    }
    
    // Inject script
    const script = document.createElement('script')
    script.id = 'cf-turnstile-script'
    script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
    script.async = true
    script.defer = true
    script.onload = () => setLoaded(true)
    
    document.head.appendChild(script)
  }, [])
  
  return loaded
}

// Usage
function MyForm() {
  const scriptLoaded = useLoadTurnstile()
  
  if (!scriptLoaded) {
    return <div>Loading...</div>
  }
  
  return (
    <Turnstile
      siteKey="your-site-key"
      injectScript={false}
    />
  )
}
Load once for all pages:Next.js Pages Router (_app.tsx):
import type { AppProps } from 'next/app'
import Script from 'next/script'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Script
        src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
        strategy="afterInteractive"
      />
      <Component {...pageProps} />
    </>
  )
}
Next.js App Router (layout.tsx):
import Script from 'next/script'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head>
        <Script
          src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
          strategy="afterInteractive"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}
Then use injectScript={false} on all widgets:
<Turnstile siteKey="your-site-key" injectScript={false} />

Script Load Callbacks

React when the script finishes loading:

onLoadScript Callback

<Turnstile
  siteKey="your-site-key"
  onLoadScript={() => {
    console.log('Turnstile script loaded')
    console.log('window.turnstile available:', !!window.turnstile)
  }}
/>

Script Error Handling

<Turnstile
  siteKey="your-site-key"
  scriptOptions={{
    onError: () => {
      console.error('Failed to load Turnstile script')
      // Show user-friendly error message
      alert('Unable to load verification widget. Please check your internet connection.')
    }
  }}
/>

Script Loading States

The library tracks script loading internally using a state machine:
lib.tsx:22-46
let turnstileState: 'unloaded' | 'loading' | 'ready' = 'unloaded'

const ensureTurnstile = (onLoadCallbackName = DEFAULT_ONLOAD_NAME) => {
  if (turnstileState === 'unloaded') {
    turnstileState = 'loading'
    window[onLoadCallbackName] = () => {
      turnstileLoad.resolve()
      turnstileState = 'ready'
      delete window[onLoadCallbackName]
    }
  }
  return turnstileLoadPromise
}

States

  • unloaded: Script hasn’t been injected yet
  • loading: Script is currently loading
  • ready: Script loaded, window.turnstile is available
This state is shared globally across all Turnstile instances on the page.

Multiple Widgets

When you have multiple Turnstile widgets on the same page:
function MultipleWidgets() {
  return (
    <>
      {/* First widget injects script */}
      <Turnstile siteKey="your-site-key" />
      
      {/* Subsequent widgets wait for the same script */}
      <Turnstile siteKey="your-site-key" />
      <Turnstile siteKey="your-site-key" />
    </>
  )
}
Behavior:
  1. First widget injects the script (if not already present)
  2. All widgets wait for turnstileState === 'ready'
  3. Each widget renders independently once the script is loaded
  4. Only one script tag exists in the DOM

Troubleshooting

Symptoms: Widget never appears, console shows no errorsSolutions:
  1. Check if the script is being blocked by ad blockers or privacy extensions
  2. Verify CSP headers allow https://challenges.cloudflare.com
  3. Check network tab for failed script requests
  4. Add onLoadScript and scriptOptions.onError to debug:
<Turnstile
  siteKey="your-site-key"
  onLoadScript={() => console.log('Script loaded')}
  scriptOptions={{
    onError: () => console.error('Script failed to load')
  }}
/>
Symptoms: Console errors like “Refused to load script” or “Refused to frame”Solutions:
  1. Add Cloudflare domains to CSP:
Content-Security-Policy: 
  script-src 'nonce-NONCE' https://challenges.cloudflare.com;
  frame-src https://challenges.cloudflare.com;
  style-src 'nonce-NONCE' https://challenges.cloudflare.com;
  1. Pass nonce to component:
<Turnstile
  siteKey="your-site-key"
  scriptOptions={{ nonce: 'YOUR_NONCE' }}
/>
Symptoms: Multiple script tags with same src in DOMSolutions: This shouldn’t happen due to built-in duplicate prevention, but if it does:
  1. Use injectScript={false} and inject manually once
  2. Check for conflicting script injection libraries
  3. Ensure all Turnstile instances use the same scriptOptions.id
Symptoms: Widget doesn’t render with injectScript={false}Solutions:
  1. Ensure script URL includes ?render=explicit:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
  1. Wait for script to load before rendering widget:
const [scriptReady, setScriptReady] = useState(false)

useEffect(() => {
  if (window.turnstile) {
    setScriptReady(true)
  }
}, [])

if (!scriptReady) return <div>Loading...</div>

return <Turnstile siteKey="your-site-key" injectScript={false} />

Best Practices

Use Auto-Injection

Let the component handle script injection unless you have a specific reason not to

Include Nonce for CSP

Always provide a nonce when using Content Security Policy

Handle Load Errors

Implement scriptOptions.onError to gracefully handle script load failures

One Script Per Page

Don’t manually inject the script if you’re also using auto-injection

Next Steps

Component Props

Explore all component configuration options

Widget Lifecycle

Understand widget lifecycle and callbacks

Script Options API

View all script configuration options

Troubleshooting

Handle script and widget errors

Build docs developers (and LLMs) love