Sheila the cat sitting in front of a desktop PC

Fix Turnstile widget not showing on Astro contact page (site key and script load)


I deployed a contact form that passed npm run build, looked fine in the dashboard, and then shipped a /contact page with no Turnstile widget at all. The form fields rendered, the submit button was grayed out in production, and there was nothing to click for bot verification.

That is a different failure mode than a 503 or 403 after submit:

SymptomLikely causePost
No widget on the pageSite key missing at SSR, or api.js blockedThis post
503 on submitTURNSTILE_SECRET_KEY missing server-side503 Turnstile not configured
403 after submitKeys present but siteverify failedTurnstile verification failed

If the challenge never appears, stop debugging siteverify and fix what contact.astro decides at render time.

How the widget is gated

In src/pages/contact.astro:

const turnstileSiteKey = readEnv('PUBLIC_TURNSTILE_SITE_KEY', Astro.locals);
const turnstileEnabled = Boolean(turnstileSiteKey);

When turnstileEnabled is false:

  1. The Turnstile script tag is not emitted in <head>.
  2. The <div class="cf-turnstile" data-sitekey={...} /> block is not rendered.
  3. The submit button stays disabled outside dev.

Local dev is looser—the button can submit without a widget—but production will not.

Fast diagnosis checklist

1) View source: is api.js in the HTML?

Open /contact in production and search for:

challenges.cloudflare.com/turnstile/v0/api.js

Not foundturnstileEnabled was false at SSR. Jump to steps 2–4.

Found, but no widget → confirm data-sitekey is in the HTML and check DevTools for blocked scripts (step 5).

2) Confirm PUBLIC_TURNSTILE_SITE_KEY on the Worker

In Cloudflare → Workers → your service → Settings → Variables and Secrets, add a plain text variable:

PUBLIC_TURNSTILE_SITE_KEY = <site key from Turnstile widget>

Pair it with TURNSTILE_SECRET_KEY as a secret for server verification. The public key enables the widget; the secret prevents 503 on submit. Copy both from the same Turnstile widget.

3) Verify readEnv sees the key at request time

contact.astro calls readEnv at SSR. If you added the variable in the dashboard but never redeployed, or typoed the name, readEnv returns undefined and the widget path is skipped.

See Astro secrets on Cloudflare Workers for how bindings flow here. After redeploy, view source and confirm data-sitekey="0x..." inside .cf-turnstile.

4) Local vs production env files

For astro dev with the Cloudflare adapter, put the site key in .dev.vars:

PUBLIC_TURNSTILE_SITE_KEY=your_site_key
TURNSTILE_SECRET_KEY=your_secret_key

If the widget shows locally but not in production, the production Worker variable is missing—not the Turnstile widget config.

5) Script loads but widget stays blank

In DevTools → Console and Network:

  • Blocked script — ad blockers or a strict CSP without https://challenges.cloudflare.com in script-src and frame-src.
  • Invalid site key — re-copy from the widget that lists your production hostname.
  • Hostname not allowed — add apex, www, and staging hosts under the widget’s hostname settings.

6) Widget missing vs verification failed

CheckWidget missingVerification failed (403)
api.js in HTMLNoYes
Submit in prodDisabledEnabled after challenge
API responseN/A403 JSON error

Deploy checklist

  1. Set PUBLIC_TURNSTILE_SITE_KEY as a Worker variable and TURNSTILE_SECRET_KEY as a secret.
  2. npm run build and deploy.
  3. Hard-refresh /contact.
  4. View source — confirm api.js and data-sitekey.
  5. Complete the challenge and submit once.

For greenfield setup, see Turnstile + Resend contact form: complete setup.

What fixed my blank widget

The site key was in .dev.vars but never added as a public Worker variable in the Cloudflare dashboard. SSR rendered turnstileEnabled = false, so the script and widget never made it into the HTML. One dashboard variable and a redeploy brought the challenge back.


Hero photo: Sheila the PC cat by Kormoran, CC BY-SA 3.0.