# How to stop contact form spam

> Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.

Published 2026-10-25 · https://ikiform.com/guides/spam-protection

Stack cheap, invisible checks before you add any friction: a hidden honeypot field, a minimum time to fill the form, a score for links and spam phrases, and a rate limit per visitor. Add a human check such as Cloudflare Turnstile only when spam still gets through, and verify its token on a server. Store what you catch instead of throwing it away, so you can rescue the odd real message.

A contact form on a public page starts getting junk within days of being indexed. Most of it isn't aimed at you. Scripts crawl the web for any `<form>`, fill every input and post it, hoping a link lands somewhere. A smaller share comes from real browsers driven by software, and some from people paid to paste the same pitch into thousands of forms. Each kind is stopped by a different check, which is why one trick never fixes it.

This guide explains each defence, what it catches and what it costs real visitors, with code you can run on any stack. Then it shows how Ikiform, a form backend, runs the same layers for you.

## Layers, cheapest first

Order the checks by what they cost. Counting requests is nearly free, so it runs before anything reads the body. Checks that call another service, like a captcha, run once the cheap ones pass. Scoring the content comes last, because it needs the parsed fields. Ikiform runs its checks in this order on every post.

Diagram: The order spam checks run in, from a post arriving to stored or spam

1. A post arrives at the form endpoint.
2. The rate limit counts posts per visitor first. A flood is refused with 429 before the body is read.
3. Allowed domains compare the page the post came from with the domains you listed.
4. If the human check is on, the post must carry a valid Turnstile token.
5. The honeypot adds 5 to the score when it is filled in.
6. Timing, links, known spam phrases, disposable addresses and repeats add smaller amounts.
7. At 5 or more the post is stored as spam. Below it, the post is received and your email and webhooks go out.

The top row refuses posts. The bottom row never refuses: it scores, so a false positive is a click to restore, not a lost message.

## The defences compared

Every defence trades something. The table is honest about what each one misses and what it asks of the people you want to hear from.

| Defence | Stops | Misses | Cost to real visitors |
| --- | --- | --- | --- |
| [Honeypot](https://ikiform.com/guides/spam-protection/honeypot.md) | Scripts that fill every input | Bots that read CSS or skip hidden fields, and humans | None, if it is hidden from screen readers and autofill |
| Time trap | Posts sent faster than anyone can type | Slow bots, and bots that wait | None, as long as the minimum is a few seconds |
| Content scoring, such as link counts, spam phrases or Akismet | SEO pitches, link drops, casino and pill spam | Clean-looking spam, and it can flag a real message with links | None, if you review what it catches |
| [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md) | Floods and scripts in a loop | Slow, spread-out spam | Offices and events behind one IP can hit a tight limit |
| Allowed domains | Posts sent straight to your endpoint from elsewhere | Scripts that fake the `Origin` header | None |
| [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md) | Most automated browsers | Paid human spammers | Usually invisible. Sometimes a click, and it needs a script |
| [reCAPTCHA or hCaptcha](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md) | Most automated browsers | Paid human spammers and solving services | Image puzzles for some visitors, plus a third-party script |

Start with the first five. They are invisible and catch the bulk of what a small site gets. Add a human check when the dashboard tells you spam still gets through, not before: it is the only layer with a cost every visitor can feel. The page on [stopping spam without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md) shows how far the invisible layers go on their own.

## Read the spam before you fight it

Look at ten spam messages before you choose a fix. What they have in common tells you who sends them, and that points to the layer that stops them.

Diagram: From what spam looks like to the layer that stops it

1. Random text in every field comes from a script that fills all inputs. A honeypot catches it.
2. Dozens of posts a minute from one source come from a script in a loop. A rate limit stops it.
3. Tidy posts sent within a second of the page loading come from a headless browser. A time trap or a human check stops it.
4. Polite pitches with links come from people paid per form. Only content scoring and your own review catch them.

No captcha stops the last row. A person can solve it, so content scoring and a weekly look at the spam folder are the only defence.

## A contact form with the layers wired in

Here is one contact form, built for each stack. Every version has the off-screen honeypot and a human check. The browser-only versions use Ikiform's managed Turnstile check, which needs no keys. The server versions show the do-it-yourself route: your server verifies a Turnstile token with your own Cloudflare keys, then forwards the fields. Pick your stack below; the choice carries over to every code sample in these guides.

The server tabs share two small helpers. `verifyTurnstile` asks Cloudflare whether a token is real, and `sendToIkiform` forwards the fields with your secret key and the visitor's `Origin` header. A server-side fetch sends no `Origin` of its own, and Ikiform reads it to recognise a free `localhost` test. Both fail closed: if Cloudflare can't be reached, the visitor sees an error rather than a silent pass.

`lib/spam.ts`:

```ts
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export interface Contact {
  name: string;
  email: string;
  message: string;
  _gotcha: string;
}

export function readContact(data: FormData): Contact {
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    message: String(data.get("message") ?? "").trim(),
    _gotcha: String(data.get("_gotcha") ?? ""),
  };
}

export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
  if (!input.token || input.token.length > 2048) return false;
  const body = new URLSearchParams({ secret: input.secret, response: input.token });
  if (input.ip) body.set("remoteip", input.ip);
  try {
    const response = await fetch(SITEVERIFY, { method: "POST", body });
    const result = (await response.json()) as { success?: boolean };
    return result.success === true;
  } catch {
    return false;
  }
}

export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${secretKey}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(contact),
    });
    const result = await response.json().catch(() => null);
    if (result?.ok) return { ok: true as const };
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.values(fields)[0]?.[0];
    return {
      ok: false as const,
      status: response.status,
      message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
    };
  } catch {
    return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
  }
}
```

**HTML**

The plain form needs no script. Set the Ikiform form's **Spam check** to **Ikiform**, and a post without a token gets a short check page on ikiform.com that runs Turnstile and sends the fields on. People who pass it invisibly never notice.

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required maxlength="120" />

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10" maxlength="5000"></textarea>

  <div class="hp" aria-hidden="true">
    <label for="hp-field">Leave this empty</label>
    <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>

  <button>Send message</button>
</form>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
```

The honeypot sits off-screen rather than behind `display: none`, because some bots skip fields that are hidden that way. The wrapper's `aria-hidden` and the input's `tabindex` keep screen readers and keyboards away from it.

**Next.js**

A server action verifies the Turnstile token with your own keys, then forwards the fields with your Ikiform secret key. The honeypot goes along untouched, so a filled one is stored as spam where you can review it. Save the helpers above as `lib/spam.ts`. Set `TURNSTILE_SECRET_KEY` and `IKIFORM_SECRET_KEY` as server-only environment variables, and leave the Ikiform form's **Spam check** set to **Off**: your server already ran the human check.

`app/contact/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { readContact, sendToIkiform, verifyTurnstile } from "@/lib/spam";

export interface ContactState {
  ok: boolean;
  message: string;
  values?: { name: string; email: string; message: string };
}

export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
  const contact = readContact(formData);
  const values = { name: contact.name, email: contact.email, message: contact.message };
  const incoming = await headers();
  const forwarded = incoming.get("x-forwarded-for");

  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: forwarded?.split(",")[0]?.trim(),
  });
  if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again.", values };

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, contact, incoming.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return { ok: false, message: result.message, values };
}
```

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { Turnstile } from "@/components/turnstile";
import { type ContactState, sendMessage } from "./actions";

const initial: ContactState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendMessage, initial);

  if (state.ok) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} defaultValue={state.values?.name} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} defaultValue={state.values?.message} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} reset={state} />
      {state.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

`components/turnstile.tsx`:

```tsx
"use client";

import { useEffect, useRef } from "react";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action?: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    if (window.turnstile) return resolve(window.turnstile);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => window.turnstile && resolve(window.turnstile), { once: true });
  });
}

export function Turnstile({ siteKey, reset }: { siteKey: string; reset?: unknown }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    let active = true;
    loadTurnstile().then((turnstile) => {
      if (active && box.current) widgetId = turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    });
    return () => {
      active = false;
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey, reset]);

  return <div ref={box} />;
}
```

The widget renders explicitly and re-renders whenever the action returns, because a Turnstile token works once. `defaultValue` puts the visitor's text back after an error, since React resets a form after its action runs.

Going further with Next.js? Read [Next.js form spam protection with Turnstile](https://ikiform.com/guides/spam-protection/nextjs.md).

**Astro**

An Astro Action validates the fields with Zod, checks the token against `context.clientAddress` and forwards the post. Save the helpers above as `src/lib/spam.ts` and read both keys with `getSecret` from `astro:env/server`. The form posts without client JavaScript, so every attempt reloads the page and the widget renders fresh. Add an adapter and keep `prerender = false`. Set `TURNSTILE_SECRET_KEY` and `IKIFORM_SECRET_KEY` as server-only environment variables, and leave the Ikiform form's **Spam check** set to **Off**: your server already ran the human check.

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
import { sendToIkiform, verifyTurnstile } from "../lib/spam";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1).max(120),
      email: z.email(),
      message: z.string().trim().min(10).max(5000),
      _gotcha: z.string().nullish(),
      "cf-turnstile-response": z.string().nullish(),
    }),
    handler: async (input, context) => {
      const human = await verifyTurnstile({
        secret: getSecret("TURNSTILE_SECRET_KEY") ?? "",
        token: input["cf-turnstile-response"] ?? "",
        ip: context.clientAddress,
      });
      if (!human) throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try the check again." });

      const result = await sendToIkiform(getSecret("IKIFORM_SECRET_KEY") ?? "", {
        name: input.name,
        email: input.email,
        message: input.message,
        _gotcha: input._gotcha ?? "",
      }, context.request.headers.get("origin"));
      if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.message });
      return { sent: true };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---

{result?.data?.sent ? (
  <p role="status">Thanks. We'll reply by email.</p>
) : (
  <form method="POST" action={actions.sendMessage}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={siteKey} data-action="contact"></div>
    {result?.error && (
      <p role="alert">{isInputError(result.error) ? "Check your name, email and message." : result.error.message}</p>
    )}
    <button>Send message</button>
  </form>
)}

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```

Going further with Astro? Read [Astro form spam protection with Turnstile](https://ikiform.com/guides/spam-protection/astro.md).

**TanStack Start**

A `createServerFn` POST function reads the visitor's IP with `getRequestHeader`, verifies the token and forwards the fields. The widget component is the same one as in the Next.js tab, at `src/components/turnstile.tsx`. Set `TURNSTILE_SECRET_KEY` and `IKIFORM_SECRET_KEY` as server-only environment variables, and leave the Ikiform form's **Spam check** set to **Off**: your server already ran the human check.

`src/routes/contact.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";

const sendMessage = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return { contact: readContact(data), token: String(data.get("cf-turnstile-response") ?? "") };
  })
  .handler(async ({ data }) => {
    const human = await verifyTurnstile({
      secret: process.env.TURNSTILE_SECRET_KEY!,
      token: data.token,
      ip: getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim(),
    });
    if (!human) return { ok: false, message: "We couldn't confirm you're human. Try the check again." };
    const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, data.contact, getRequestHeader("origin"));
    return result.ok ? { ok: true, message: "Thanks. We'll reply by email." } : { ok: false, message: result.message };
  });

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const send = useServerFn(sendMessage);
  const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setState(await send({ data }));
    } catch {
      setState({ ok: false, message: "Couldn't send your message. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (state?.ok) return <p role="status">{state.message}</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={state} />
      {state?.message ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Going further with TanStack Start? Read [TanStack Start form spam protection with Turnstile](https://ikiform.com/guides/spam-protection/tanstack-start.md).

**React Router**

The route's `action` verifies the token and forwards the post. Passing `actionData` as `reset` renders a fresh widget after every answer. The widget component is the one from the Next.js tab. Set `TURNSTILE_SECRET_KEY` and `IKIFORM_SECRET_KEY` as server-only environment variables, and leave the Ikiform form's **Spam check** set to **Off**: your server already ran the human check.

`app/routes/contact.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import { Turnstile } from "../components/turnstile";
import { readContact, sendToIkiform, verifyTurnstile } from "../lib/spam";
import type { Route } from "./+types/contact";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const human = await verifyTurnstile({
    secret: process.env.TURNSTILE_SECRET_KEY!,
    token: String(formData.get("cf-turnstile-response") ?? ""),
    ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
  });
  if (!human) return data({ ok: false, message: "We couldn't confirm you're human. Try the check again." }, { status: 403 });

  const result = await sendToIkiform(process.env.IKIFORM_SECRET_KEY!, readContact(formData), request.headers.get("origin"));
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  return data({ ok: false, message: result.message }, { status: result.status });
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} reset={actionData} />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

Going further with React Router? Read [React Router form spam protection with Turnstile](https://ikiform.com/guides/spam-protection/react-router.md).

**SvelteKit**

A default form action verifies the token with `getClientAddress()` and forwards the post. This version leaves out `use:enhance` on purpose: each attempt reloads the page, so Turnstile renders a new widget with a new token. Save the helpers above as `src/lib/spam.ts`. Declare the keys in `src/env.ts` with `defineEnvVars` and give `PUBLIC_TURNSTILE_SITE_KEY` `public: true`, so the page can import it from `$app/env/public`. Set `TURNSTILE_SECRET_KEY` and `IKIFORM_SECRET_KEY` as server-only environment variables, and leave the Ikiform form's **Spam check** set to **Off**: your server already ran the human check.

`src/routes/contact/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY, TURNSTILE_SECRET_KEY } from "$app/env/private";
import { readContact, sendToIkiform, verifyTurnstile } from "#lib/spam.js";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, getClientAddress, url }) => {
    const data = await request.formData();
    const contact = readContact(data);
    const values = { name: contact.name, email: contact.email, message: contact.message };

    const human = await verifyTurnstile({
      secret: TURNSTILE_SECRET_KEY,
      token: String(data.get("cf-turnstile-response") ?? ""),
      ip: getClientAddress(),
    });
    if (!human) return fail(403, { values, error: "We couldn't confirm you're human. Try the check again." });

    const result = await sendToIkiform(IKIFORM_SECRET_KEY, contact, request.headers.get("origin") ?? url.origin);
    if (!result.ok) return fail(result.status, { values, error: result.message });
    return { sent: true };
  },
} satisfies Actions;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
</script>

<svelte:head>
  <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>

{#if form?.sent}
  <p role="status">Thanks. We'll reply by email.</p>
{:else}
  <form method="POST">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" value={form?.values?.message ?? ""}></textarea>
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
    {#if form?.error}
      <p role="alert">{form.error}</p>
    {/if}
    <button>Send message</button>
  </form>
{/if}
```

Going further with SvelteKit? Read [SvelteKit form spam protection with Turnstile](https://ikiform.com/guides/spam-protection/sveltekit.md).

**Nuxt**

A server route verifies the token and forwards the post. Save the helpers above as `server/utils/spam.ts` and Nuxt auto-imports them. The component renders Turnstile explicitly and resets it after a failed attempt. Set `TURNSTILE_SECRET_KEY` and `IKIFORM_SECRET_KEY` as server-only environment variables, and leave the Ikiform form's **Spam check** set to **Off**: your server already ran the human check.

`server/api/contact.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const body = await readBody<Record<string, string | undefined>>(event);
  const config = useRuntimeConfig(event);

  const human = await verifyTurnstile({
    secret: config.turnstileSecretKey,
    token: body["cf-turnstile-response"] ?? "",
    ip: getRequestIP(event, { xForwardedFor: true }),
  });
  if (!human) throw createError({ status: 403, message: "We couldn't confirm you're human. Try the check again." });

  const result = await sendToIkiform(config.ikiformSecretKey, {
    name: body.name?.trim() ?? "",
    email: body.email?.trim() ?? "",
    message: body.message?.trim() ?? "",
    _gotcha: body._gotcha ?? "",
  }, getRequestHeader(event, "origin"));
  if (!result.ok) throw createError({ status: result.status, message: result.message });
  return { sent: true };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
    turnstileSecretKey: "",
    public: {
      turnstileSiteKey: "",
    },
  },
});
```

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
}

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const config = useRuntimeConfig();
const fields = reactive({ name: "", email: "", message: "", _gotcha: "" });
const box = ref<HTMLDivElement>();
const token = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
let widgetId: string | undefined;

function api() {
  return (window as Window & { turnstile?: TurnstileApi }).turnstile;
}

function loadTurnstile() {
  return new Promise<TurnstileApi>((resolve) => {
    const ready = api();
    if (ready) return resolve(ready);
    let script = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT}"]`);
    if (!script) {
      script = document.createElement("script");
      script.src = SCRIPT;
      script.async = true;
      document.head.append(script);
    }
    script.addEventListener("load", () => api() && resolve(api()!), { once: true });
  });
}

onMounted(async () => {
  const turnstile = await loadTurnstile();
  if (!box.value) return;
  widgetId = turnstile.render(box.value, {
    sitekey: config.public.turnstileSiteKey,
    action: "contact",
    callback: (value) => (token.value = value),
    "expired-callback": () => (token.value = ""),
  });
});

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    token.value = "";
    if (widgetId) api()?.reset(widgetId);
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

Going further with Nuxt? Read [Nuxt form spam protection with Turnstile](https://ikiform.com/guides/spam-protection/nuxt.md).

**React**

A single-page app has no server to keep a Turnstile secret, so it uses Ikiform's managed check instead. `challenge()` from `@ikiform/sdk` mounts a small ikiform.com frame that stays invisible unless Turnstile needs a click, and returns a token for `submit()`. Set **Spam check** to **Ikiform** and add your domain under **Allowed domains**.

`src/ContactForm.tsx`:

```tsx
import { challenge, submit } from "@ikiform/sdk";
import { type FormEvent, useRef, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const check = useRef<HTMLDivElement>(null);
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setStatus("sending");
    setError("");
    try {
      const turnstileToken = await challenge(FORM_ID, { container: check.current ?? undefined });
      const result = await submit(FORM_ID, data, { turnstileToken });
      if (result.ok) {
        setStatus("sent");
        return;
      }
      setError(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
    } catch {
      setError("The human check didn't finish. Try again.");
    }
    setStatus("idle");
  }

  if (status === "sent") return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <div ref={check} />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

The same managed check in a Vue single-file component. The frame appears inside the empty `div` only when a visitor has to click.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { challenge, submit } from "@ikiform/sdk";
import { ref } from "vue";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const check = ref<HTMLDivElement>();
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send(event: SubmitEvent) {
  const data = new FormData(event.target as HTMLFormElement);
  status.value = "sending";
  error.value = "";
  try {
    const turnstileToken = await challenge(FORM_ID, { container: check.value });
    const result = await submit(FORM_ID, data, { turnstileToken });
    if (result.ok) {
      status.value = "sent";
      return;
    }
    error.value = Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message;
  } catch {
    error.value = "The human check didn't finish. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="check" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Every version uses the `.hp` class from the HTML tab's style block. Put it in your global CSS.
- The honeypot is named `_gotcha`, Ikiform's default. The server versions forward it untouched, so a filled one still lands in your spam folder with the reason `honeypot`.
- Turnstile adds a hidden `cf-turnstile-response` input to the form. A token is valid for 300 seconds and works once, so each attempt needs a new one.
- Test keys from Cloudflare let you run all of this locally. The [Turnstile guide](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md) lists them.

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `IKIFORM_SECRET_KEY` in server-only environment variables.

Try the field definition below. It runs Ikiform's validator in your browser, the same rules the server applies before any spam score is counted.

Form: Contact

- **Name** (`name`, text, required)
- **Email** (`email`, email, required)
- **Topic** (`topic`, select)
- **Message** (`message`, textarea, required)

The same fields as the [contact form template](https://ikiform.com/forms/contact-form). Paste the definition on the form's **Fields** tab and Ikiform refuses posts that don't match it with `422 validation_failed`.

## The honeypot

A honeypot is an input that people never see and bots fill in. If it has a value, the post came from software. It costs visitors nothing and catches the oldest, most common bots, the ones that parse the HTML and fill every input they find.

Hiding it well matters more than the trick itself. Three things go wrong with careless honeypots:

- **Screen readers announce it.** A blind visitor hears an extra field, fills it in, and gets marked as spam. Wrap it in `aria-hidden="true"` and give it a label that says to leave it empty.
- **Keyboards land on it.** Tabbing through the form stops on an invisible input. `tabindex="-1"` takes it out of the tab order.
- **Autofill fills it.** Browsers and password managers fill fields by name. Call the field `email2` or `phone` and some will fill it for real people. Use a name no autofill recognises, such as `_gotcha`, and add `autocomplete="off"`.

The [honeypot guide](https://ikiform.com/guides/spam-protection/honeypot.md) covers hiding techniques, how bots spot them, and the server-side check for each stack. With Ikiform, a filled honeypot adds 5 to the score, which reaches the threshold of 5 on its own. You can rename the field per form under **Honeypot field**.

## Timing

People take seconds to fill in a contact form. Many bots take milliseconds. A time trap records when the form was rendered and checks the gap when it is posted. The naive version puts a timestamp in a hidden field:

`Time trap, the naive version`:

```ts
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;
```

A bot can send any number it likes, so a plain timestamp only stops bots that don't bother. Sign it on the server with a secret, and the bot can't fake a slower render. The page on [stopping spam without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md#signed-timestamp) builds the signed version.

Ikiform signs render times for you when the form comes from the SDK or the [shadcn/ui component](https://ikiform.com/docs/shadcn.md): they send `_ikiform_ts`, and a post less than 2 seconds after render adds 3 to the score. A forged or day-old timestamp adds 3. Plain HTML forms don't send one and aren't penalised.

## Scoring the content

Some spam is typed or pasted by people, so it passes every bot check. What gives it away is the content: links, sales phrases, throwaway addresses and the same text twice. Score each signal and treat a high total as spam, rather than blocking on any single one. One link in a message is normal; a link in the name field never is.

| Signal | Ikiform adds |
| --- | --- |
| The honeypot is filled in | +5 |
| A link in a name or subject field | +3 |
| The same fields sent to this form in the last 24 hours | +3 |
| Sent less than 2 seconds after the signed render time | +3 |
| Three or more links across all fields | +2 |
| One of 22 known spam phrases, such as "seo services" | +2 |
| An address on a disposable email domain | +2 |

At 5 or more the post is spam. No single soft signal gets there: a real message with three links scores 2. Two together often do, such as a link in the name and a spam phrase. If you score content yourself, Akismet is the long-standing hosted option; many teams start with a few regular expressions like the ones above.

## A human check

A human check asks the browser to prove it is driven by a person. Modern ones, like Cloudflare Turnstile, mostly run invisibly and only show a checkbox when unsure. The widget gives the browser a token; your server must send that token to the provider to verify it. A token that is never verified on a server stops nothing, because a bot can skip the widget and post without it.

Ikiform has three settings under **Spam check** on each form:

| Setting | What happens | Code changes |
| --- | --- | --- |
| **Off** (the default) | No challenge. The honeypot, timing and scoring still run. | None |
| **Ikiform** | Turnstile run by Ikiform on ikiform.com, with no Cloudflare account or keys | None for HTML posts. `fetch` posts call `challenge()` from `@ikiform/sdk` first |
| **Your own Turnstile keys** | Your widget and secret key. Ikiform verifies the token and checks its hostname against your allowed domains | Render the widget in your form so it sends `cf-turnstile-response` |

A `fetch` post without a token gets `403 challenge_required`. If Cloudflare can't be reached, Ikiform keeps the post and stores it as spam with the reason `captcha_unavailable`, so you can restore it. The [spam docs](https://ikiform.com/docs/spam.md#human-check) show both flows. If you're weighing Turnstile against Google's reCAPTCHA or hCaptcha, see [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md).

## Rate limits and allowed domains

A rate limit caps how often one visitor can post. It doesn't tell spam from mail; it stops volume. Count per IP and form, allow a small burst, and answer `429` with a `Retry-After` header. The [rate limiting guide](https://ikiform.com/guides/spam-protection/rate-limiting.md) builds one for each stack and explains why an in-memory counter fails on serverless hosts.

Ikiform counts every post exactly, before reading the body. Each form picks a preset: Standard, 5 a minute per visitor, Relaxed, 20 a minute per visitor, or Strict, 2 every 10 minutes per visitor. Standard also allows at most 3 every 10 seconds, and the whole form takes 300 a minute. A contact form suits Strict, since nobody honest writes twice in ten minutes. The numbers are on [Rate limits](https://ikiform.com/docs/rate-limits.md).

**Allowed domains** lists the sites your form lives on. A post from any other page is marked as spam, or refused with `403 origin_not_allowed` if you choose **Reject**. It stops people from pointing their own forms at your endpoint. A determined script can fake the header it checks, so treat it as one layer, not a lock.

## What happens to spam

Never tell a bot it was caught. An error teaches it what to change; a normal success page teaches it nothing. And never delete spam on arrival: every filter catches a real message now and then, and a deleted one is gone.

A post judged as spam gets the same success answer as a real one, so a bot learns nothing. It is stored with the status `spam`, sends no notification email and no webhook, doesn't count toward your usage, and is deleted after 30 days. Every Monday, a workspace that blocked spam gets one summary email with the total, the busiest forms and the top reasons.

Open the dashboard's status filter and pick **Spam** to see each one with its score and reasons. **Not spam** moves it back, and its email and webhooks go out then. Turn the Monday email off under **Weekly spam summary** in workspace settings. Spam is kept for 30 days, so check at least once a month.

> If one real message keeps getting caught, read its reasons. A link in a field called `business_name` scores as a link in a name; rename the field to `business` and it no longer counts as a name field.

## Pick your situation

Each of these pages goes deeper on one defence or one kind of site, with code for that case.

- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
- [Static sites](https://ikiform.com/guides/spam-protection/static-site.md): Stop spam on a static site form even though it can't keep a secret or count posts, with a honeypot partial, a safe script and server-side checks.
- [Webflow form spam](https://ikiform.com/guides/spam-protection/webflow.md): Protect a Webflow form that posts to a form backend, where Webflow's own spam settings never run, with an off-screen honeypot and a human check.
- [Next.js](https://ikiform.com/guides/spam-protection/nextjs.md): Protect a Next.js form from spam: a honeypot, Turnstile checked in a server action, a per-IP limit on Vercel, Netlify or Cloudflare, and a CSP for it.
- [Astro](https://ikiform.com/guides/spam-protection/astro.md): Protect an Astro form from spam with an Action: a honeypot, Turnstile checked with astro:env secrets, a per-IP limit and Astro's built-in CSP.
- [TanStack Start](https://ikiform.com/guides/spam-protection/tanstack-start.md): Protect a TanStack Start form from spam: function middleware for the client IP, a token bucket, Turnstile verified in a server function, and a CSP.
- [React Router](https://ikiform.com/guides/spam-protection/react-router.md): Protect a React Router form from spam: a route action with Turnstile, a Workers rate limit keyed on cf-connecting-ip, a .server module and a CSP header.
- [SvelteKit](https://ikiform.com/guides/spam-protection/sveltekit.md): Protect a SvelteKit form from spam: a form action with getClientAddress, a per-IP limit, Turnstile behind use:enhance, and a CSP that allows it.
- [Nuxt](https://ikiform.com/guides/spam-protection/nuxt.md): Protect a Nuxt form from spam: a Nitro server route with Turnstile, a per-IP limit in Nitro storage, runtimeConfig secrets and a CSP set in routeRules.

## Check it works

Run these against a test form before you rely on it. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from `localhost`. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See [pricing](https://ikiform.com/pricing).

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
3. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
4. Post with the honeypot filled in, `_gotcha=bot`. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
5. Send a message with a link in the name field and the phrase `seo services` in the message. It is stored as spam with the reasons `url_in_name` and `keyword`.
6. Turn **Spam check** to **Ikiform** and submit the plain HTML form. You see the check page for a moment, then your thank-you page.
7. Submit the form a few times in a row. Past the limit, the form answers `429` and the visitor sees Ikiform's slow-down page.
8. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.

## Troubleshooting

| Problem | What to do |
| --- | --- |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the **Spam** filter to see the reasons, and mark it as not spam to send its email and webhooks. |
| `403 challenge_required` | The form's spam check is set to **Ikiform** and a `fetch` post had no token. Get one with `challenge(formId)` from `@ikiform/sdk` before each submit, or turn the check off. |
| `403 origin_not_allowed`, or every post lands in spam | The page's domain isn't in the form's **Allowed domains**. Add it, plus `localhost` for local tests. Subdomains of a listed domain are allowed. |
| `429 rate_limited` | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for `Retry-After`, or choose Relaxed or Custom for forms that post from one shared IP. |
| `403 captcha_failed` | The Turnstile token was missing, expired or used twice. Tokens live 300 seconds and work once, so render a fresh widget, or call `challenge()` again, after every attempt. |
| Spam still arrives as received | Open one and read what it has in common with the rest. Scripted spam calls for **Spam check** set to **Ikiform**; typed pitches call for a stricter rate limit and a weekly review. |
| Real visitors report they can't send | Check that the honeypot is hidden from screen readers and named so autofill skips it, and that a strict rate limit isn't catching people behind one office IP. |

## Questions

### What is the best way to stop contact form spam?

Layers, not one trick. A hidden honeypot, a minimum time to fill the form, a content score and a rate limit stop most spam with no cost to real visitors. Add a human check such as Cloudflare Turnstile if spam still gets through, and verify its token on a server.

### Do I need a captcha on my contact form?

Not at first. Most contact form spam comes from simple bots that a honeypot and a rate limit stop. Add a captcha when your spam folder shows bots still getting through, and prefer an invisible one such as Turnstile over image puzzles.

### Why am I getting spam from real people?

Some spam is pasted by people paid per form, usually SEO or development pitches. Bot checks can't stop a person. Content scoring catches many by their links and phrases; the rest you delete in the spam folder.

### Does Ikiform tell the sender their message was marked as spam?

No. Spam gets the same success answer as a real message, so bots learn nothing. It is stored for 30 days, sends no email or webhook, and you can mark it as not spam from the dashboard.

### Do I need a Cloudflare account to use Turnstile with Ikiform?

No. Set the form's Spam check to Ikiform and it runs Turnstile on ikiform.com with no keys to manage. Use your own Turnstile keys only if you want your own widget on your page.

### Can a honeypot hurt accessibility?

Yes, if it is hidden carelessly. Hide it off-screen with aria-hidden on its wrapper, take it out of the tab order with tabindex -1, and give it a name that browsers don't autofill.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
- [Static sites](https://ikiform.com/guides/spam-protection/static-site.md): Stop spam on a static site form even though it can't keep a secret or count posts, with a honeypot partial, a safe script and server-side checks.
- [Webflow form spam](https://ikiform.com/guides/spam-protection/webflow.md): Protect a Webflow form that posts to a form backend, where Webflow's own spam settings never run, with an off-screen honeypot and a human check.
- [Next.js](https://ikiform.com/guides/spam-protection/nextjs.md): Protect a Next.js form from spam: a honeypot, Turnstile checked in a server action, a per-IP limit on Vercel, Netlify or Cloudflare, and a CSP for it.
- [Astro](https://ikiform.com/guides/spam-protection/astro.md): Protect an Astro form from spam with an Action: a honeypot, Turnstile checked with astro:env secrets, a per-IP limit and Astro's built-in CSP.
- [TanStack Start](https://ikiform.com/guides/spam-protection/tanstack-start.md): Protect a TanStack Start form from spam: function middleware for the client IP, a token bucket, Turnstile verified in a server function, and a CSP.
- [React Router](https://ikiform.com/guides/spam-protection/react-router.md): Protect a React Router form from spam: a route action with Turnstile, a Workers rate limit keyed on cf-connecting-ip, a .server module and a CSP header.
- [SvelteKit](https://ikiform.com/guides/spam-protection/sveltekit.md): Protect a SvelteKit form from spam: a form action with getClientAddress, a per-IP limit, Turnstile behind use:enhance, and a CSP that allows it.
- [Nuxt](https://ikiform.com/guides/spam-protection/nuxt.md): Protect a Nuxt form from spam: a Nitro server route with Turnstile, a per-IP limit in Nitro storage, runtimeConfig secrets and a CSP set in routeRules.
- [Outgrow builder forms](https://ikiform.com/guides/website-builder-forms.md): When Webflow, Netlify, Carrd or Framer forms hit a cap or a missing feature, keep the form you designed and point it at a form backend instead.
- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
- [Lead capture form](https://ikiform.com/guides/lead-capture.md): A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
