Client integration

Render the widget deliberately

Choose implicit or explicit rendering and place the widget where it matches the protected action and page lifecycle.

There are two ways to put a Turnstile widget on a page. Pick one on purpose.

Implicit rendering is the default. The Turnstile script loads, scans the page for elements with the cf-turnstile class, and renders a widget in each one. Nothing else to write. For a plain server-rendered form, this is all you need.

Explicit rendering hands control to your code. You decide when the widget appears and where. Use it for modals, single-page navigation, conditional forms, or a component that mounts more than once.

Ask for explicit mode in the script URL, and give it a function to call when it’s ready:

<script
  src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad"
  defer
></script>

Then render into a container yourself:

let widgetId

function onTurnstileLoad() {
  widgetId = turnstile.render('#contact-turnstile', {
    sitekey: '0x4AAAAAAABkMYinukE8nzKd',
    callback: token => {
      document.querySelector('#submit').disabled = false
    },
  })
}

turnstile.render() returns a widget ID. Keep it. When the modal closes or the component unmounts, call turnstile.remove(widgetId). When you need a fresh token, call turnstile.reset(widgetId).

One widget, one lifecycle

Keep one widget lifecycle per rendered container. The classic bug in single-page apps is a form component that mounts, unmounts, and mounts again without removing the old widget. Now you have two widgets, and the hidden field holds whichever token won the race. Remove on unmount, render on mount, and the problem goes away.

Load the official script from its documented URL. Do not proxy it, bundle it, or cache it on your own domain. Cloudflare updates the challenge logic behind that URL, and a stale copy stops working.

Try this: render a contact-form widget, navigate away and back in your app, and check the DOM. There should be exactly one widget and no stale token left in the form.

Keep the site key in the page and the secret on the server:

<div
  class="cf-turnstile"
  data-sitekey="YOUR_SITE_KEY"
></div>

Load the official script using the documented mode, then submit the generated token with the form. Test keyboard navigation, expiration, and a blocked script. The widget is one input to the server decision; it is not proof that the requested business action is authorized.

Lesson completed