2026-08-09 14:53:46 -04:00
# SvelteKit
2026-08-13 21:04:57 -04:00
For SvelteKit projects. The widget renders in the page; siteverify is called from a SvelteKit form action (or a `+server.ts` endpoint) server-side.
2026-08-09 14:53:46 -04:00
```svelte title="src/routes/signup/+page.svelte"
2026-08-13 21:04:57 -04:00
<script>
import { enhance } from "$app/forms";
2026-08-09 14:53:46 -04:00
import { onMount } from "svelte";
2026-08-13 21:04:57 -04:00
let turnstileContainer;
let signupWidgetId;
2026-08-09 14:53:46 -04:00
onMount(() => {
2026-08-13 21:04:57 -04:00
const render = () => {
signupWidgetId = window.turnstile.render(turnstileContainer, {
sitekey: "YOUR_SITEKEY",
action: "signup",
});
delete window.onSignupTurnstileLoad;
};
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
if (window.turnstile) {
render();
return;
2026-08-09 14:53:46 -04:00
}
2026-08-13 21:04:57 -04:00
window.onSignupTurnstileLoad = render;
const script = document.createElement("script");
script.src =
"https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onSignupTurnstileLoad&render=explicit";
script.async = true;
document.head.appendChild(script);
return () => {
delete window.onSignupTurnstileLoad;
};
});
2026-08-09 14:53:46 -04:00
</script>
2026-08-13 21:04:57 -04:00
<form
method="POST"
use:enhance={() => {
return async ({ result, update }) => {
try {
await update();
} finally {
if (result.type !== "redirect" && signupWidgetId !== undefined) {
window.turnstile.reset(signupWidgetId);
}
}
};
}}
>
2026-08-09 14:53:46 -04:00
<input name="email" type="email" required />
2026-08-13 21:04:57 -04:00
<div bind:this={turnstileContainer}></div>
<button type="submit">Sign up</button>
2026-08-09 14:53:46 -04:00
</form>
` ``
2026-08-13 21:04:57 -04:00
Form action (canonical siteverify):
2026-08-09 14:53:46 -04:00
` ``ts title="src/routes/signup/+page.server.ts"
import type { Actions } from "./$types";
import { fail } from "@sveltejs/kit";
2026-08-13 21:04:57 -04:00
import { TURNSTILE_SECRET, TURNSTILE_HOSTNAMES } from "$env/static/private";
const expectedHostnames = new Set(
(TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((h) => h.trim())
.filter(Boolean),
);
2026-08-09 14:53:46 -04:00
export const actions: Actions = {
2026-08-13 21:04:57 -04:00
default: async ({ request, getClientAddress }) => {
2026-08-09 14:53:46 -04:00
const data = await request.formData();
2026-08-13 21:04:57 -04:00
const token = data.get("cf-turnstile-response");
if (typeof token !== "string" || expectedHostnames.size === 0) {
return fail(403, { error: "Verification failed" });
}
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
2026-08-09 14:53:46 -04:00
method: "POST",
2026-08-13 21:04:57 -04:00
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: TURNSTILE_SECRET,
response: token,
remoteip: getClientAddress(),
}),
2026-08-09 14:53:46 -04:00
});
const result = await verify.json();
2026-08-13 21:04:57 -04:00
if (
verify.ok !== true ||
result.success !== true ||
result.action !== "signup" ||
!expectedHostnames.has(result.hostname)
) {
2026-08-09 14:53:46 -04:00
return fail(403, { error: "Verification failed" });
}
// process signup
return { ok: true };
},
};
` ``
2026-08-13 21:04:57 -04:00
` signup` is the stable action for this surface. Preserve an existing custom migration action and compare the returned action to the same value. Siteverify is mandatory for every widget mode, including pre-clearance. Set ` TURNSTILE_HOSTNAMES` to the deployment-specific frontend hostnames; a production value must not include ` localhost` or ` 127.0.0.1`.
In ` .env`:
` ``text
TURNSTILE_SECRET=YOUR_SECRET
` ``
The ` $env/static/private` import enforces that the secret never reaches the client bundle.
## Variant: client-side fetch to an endpoint
If you need a JSON API rather than progressive-enhancement form post, use ` +server.ts`:
` ``ts title="src/routes/api/signup/+server.ts"
import type { RequestHandler } from "./$types";
import { TURNSTILE_SECRET, TURNSTILE_HOSTNAMES } from "$env/static/private";
const expectedHostnames = new Set(
(TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((h) => h.trim())
.filter(Boolean),
);
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
const { token } = await request.json();
if (expectedHostnames.size === 0) {
return new Response("forbidden", { status: 403 });
}
const verify = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: TURNSTILE_SECRET,
response: token,
remoteip: getClientAddress(),
}),
});
const result = await verify.json();
if (
verify.ok !== true ||
result.success !== true ||
result.action !== "signup" ||
!expectedHostnames.has(result.hostname)
) {
return new Response("forbidden", { status: 403 });
}
// process signup
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
` ``
The explicit renderer above retains ` signupWidgetId`. Reset it in ` finally` when calling this endpoint so every completion path gets a fresh token:
` ``svelte
<script>
async function submit(e) {
e.preventDefault();
try {
const token = new FormData(e.currentTarget).get("cf-turnstile-response");
const res = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify({ token }),
});
const result = await res.json();
if (!res.ok || result.ok !== true) throw new Error("Submission failed");
// proceed
} catch {
// surface error
} finally {
if (signupWidgetId !== undefined) {
window.turnstile.reset(signupWidgetId);
}
}
}
</script>
` ``
2026-08-09 14:53:46 -04:00
## Substitutions
2026-08-13 21:04:57 -04:00
| Placeholder | Replace with |
| ------------------- | -------------------------------------------------------------------- |
| ` YOUR_SITEKEY` | The widget site key from Step 8 |
| ` YOUR_SECRET` | The secret captured in Step 8. Stays in env, never inlined. |