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:
Injects the script: https://challenges.cloudflare.com/turnstile/v0/api.js
Waits for it to load
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:
Script Injection Logic (utils.ts:22-71)
Constants (utils.ts:3-6)
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' } }
/>
Whether to set the injected script as defer. < Turnstile
siteKey = "your-site-key"
scriptOptions = { { defer: 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' } }
/>
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.
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:
React Component
Next.js (App Router)
Next.js Middleware
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
Method 1: HTML Script Tag
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 }
/>
Method 2: Next.js Script Component
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 }
/>
</>
)
}
Method 3: Custom Loader Hook
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 }
/>
)
}
Method 4: Import in _app or Layout
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:
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.
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:
First widget injects the script (if not already present)
All widgets wait for turnstileState === 'ready'
Each widget renders independently once the script is loaded
Only one script tag exists in the DOM
Troubleshooting
Symptoms: Widget never appears, console shows no errorsSolutions:
Check if the script is being blocked by ad blockers or privacy extensions
Verify CSP headers allow https://challenges.cloudflare.com
Check network tab for failed script requests
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:
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;
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:
Use injectScript={false} and inject manually once
Check for conflicting script injection libraries
Ensure all Turnstile instances use the same scriptOptions.id
Manual Injection Not Working
Symptoms: Widget doesn’t render with injectScript={false}Solutions:
Ensure script URL includes ?render=explicit:
< script src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" ></ script >
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