# How to stop spam on a static site contact form

> 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.

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

A static site has no server, so nothing on the page can keep a secret key, verify a captcha token or count posts per visitor. Those checks must run somewhere else: a function on your host, or a form backend that your form posts to. Put an accessible honeypot in the form either way, and keep the form working without JavaScript.

## What's different on a static site

On a static site, every byte you deploy is public. Anything in your HTML or JavaScript can be read and skipped by a bot, so client-side spam checks are advice, not enforcement. The three checks that matter most all need a server:

- **Verifying a captcha token** needs a secret key, which can't live in a public file.
- **Counting posts per visitor** needs storage that survives between requests.
- **Reading a honeypot** needs code that runs after the post arrives, out of the bot's reach.

Static hosts also don't accept posts to your pages. Vercel and GitHub Pages answer a form `POST` to a static page with `405 Method Not Allowed`, so the form needs an endpoint before spam is even a question.

Diagram: Three ways a static site form can be wired, and where spam checks run in each

1. A static page that posts to itself reaches a host that runs no code, so the post fails with 405 or goes nowhere.
2. With a host function, the page posts to a function that holds the secret, reads the honeypot and verifies the token, then sends the message on.
3. With a form backend, the page posts straight to the backend, which runs the honeypot, scoring, rate limit and human check before it emails you.

In both working rows, the checks run after the post leaves the browser. That is the only place a bot can't skip them.

## Your options

| Option | Spam checks | Trade-offs |
| --- | --- | --- |
| Netlify Forms | Akismet on every post, plus a `netlify-honeypot` field and reCAPTCHA v2 if you add them | Only on Netlify. Moving host means rebuilding the form. See [Netlify Forms alternatives](https://ikiform.com/alternatives/netlify-forms) |
| A function on your host | Whatever you write: honeypot, token checks, a rate limit with a store | You write and maintain it, and need an email or storage service behind it |
| A form backend, such as Ikiform | Honeypot, scoring, rate limits, allowed domains and a human check, with no code | Another service, priced per submission |

## The form, as a reusable partial

Whichever endpoint you choose, write the form once as a partial or include, so every page uses the same honeypot. The version below posts to Ikiform. To use a function instead, change `action` to the function's path and drop `_redirect`.

**Hugo** (`layouts/partials/contact-form.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

**Jekyll** (`_includes/contact-form.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ '/thanks/' | absolute_url }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

**Eleventy** (`_includes/contact-form.njk`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ site.url }}/thanks/" />
  <p role="status"></p>
  <button>Send message</button>
</form>
```

`assets/contact.css`:

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

- The honeypot sits off-screen, out of the tab order and hidden from screen readers. The [honeypot guide](https://ikiform.com/guides/spam-protection/honeypot.md) explains each attribute.
- `_redirect` sends people to your thank-you page. Ikiform follows it only for an `https:` address on one of the form's **Allowed domains**.
- The [Hugo](https://ikiform.com/docs/guides/hugo.md), [Jekyll](https://ikiform.com/docs/guides/jekyll.md) and [Eleventy](https://ikiform.com/docs/guides/eleventy.md) guides cover setup, shortcodes and GitHub Pages.

## Enhance it without breaking it

A script can keep visitors on the page, but it must never be the only way the form works. This one posts with `fetch` and falls back to a normal browser post in two cases: when the network call fails, and when Ikiform's human check asks for a token with `challenge_required`. A normal post gets Ikiform's check page instead, so you can turn the check on without changing the script.

`contact-form.html`:

```html
<script>
  document.querySelectorAll("form.contact").forEach((form) => {
    const status = form.querySelector("[role=status]");
    const button = form.querySelector("button");

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      try {
        const response = await fetch(form.action, {
          method: "POST",
          headers: { Accept: "application/json" },
          body: new FormData(form),
        });
        const result = await response.json();
        if (result.ok) {
          form.replaceChildren("Thanks. We'll reply by email.");
          return;
        }
        if (result.error.code === "challenge_required") {
          form.submit();
          return;
        }
        const wait = response.headers.get("Retry-After");
        if (response.status === 429 && wait) {
          status.textContent = `Too many messages. Try again in ${wait} seconds.`;
        } else {
          status.textContent = result.error.message;
        }
      } catch {
        form.submit();
        return;
      }
      button.disabled = false;
    });
  });
</script>
```

- `form.submit()` sends the form without firing the `submit` event again, so the fallback can't loop.
- A `429` shows how long to wait from `Retry-After`, which Ikiform exposes to browser code.
- The success line replaces the form, so nobody sends it twice by accident.

## If you write a host function

Netlify, Cloudflare Pages and Vercel all run small functions next to static files. A function is your own backend, so it owns the spam checks. This Netlify Function reads the honeypot, drops link-stuffed messages, verifies a Turnstile token and emails you through Resend. Every caught post gets the same thank-you redirect as a real one.

`netlify/functions/contact.mts`:

```ts
import type { Config, Context } from "@netlify/functions";

const LINK = /https?:\/\/|www\./gi;

export default async (req: Request, context: Context) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await req.formData();
  const email = String(form.get("email") ?? "");
  const message = String(form.get("message") ?? "");
  const thanks = Response.redirect(new URL("/thanks/", req.url), 303);

  if (String(form.get("_gotcha") ?? "").trim()) return thanks;
  if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;

  const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY ?? "",
      response: String(form.get("cf-turnstile-response") ?? ""),
      remoteip: context.ip,
    }),
  });
  const verdict = (await check.json()) as { success: boolean };
  if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "Contact form <form@example.com>",
      to: "you@example.com",
      reply_to: email,
      subject: "New message from the website",
      text: message,
    }),
  });
  return thanks;
};

export const config: Config = { path: "/api/contact" };
```

- `context.ip` is the visitor's IP, which siteverify can use as `remoteip`.
- Set `TURNSTILE_SECRET_KEY` and `RESEND_API_KEY` in the site's environment variables, never in the repository.
- It drops caught posts instead of storing them, so a real message caught by mistake is gone. Log them at least.
- It has no rate limit, since a function has no memory between calls. The [rate limiting guide](https://ikiform.com/guides/spam-protection/rate-limiting.md) adds one with Redis.

## What Ikiform does for a static form

With the form posting straight to Ikiform, the server-side work happens there. The honeypot is `_gotcha`, content scoring and per-visitor rate limits run on every post, and **Allowed domains** marks posts from other sites as spam. Turn **Spam check** to **Ikiform** for Cloudflare Turnstile with no keys: the plain HTML post runs it on a check page, and the script above falls back to that page. 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.

> List every host the form is published on under **Allowed domains**: your domain, plus the host's own address while you test, such as `username.github.io`. Netlify deploy previews and Vercel preview URLs aren't subdomains of your main site address; add the exact preview host while you test one.

## Check it works

1. Turn JavaScript off and submit. The form still posts and you land on your thank-you page.
2. Turn JavaScript on and submit. The form is replaced by the thank-you line.
3. 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.
4. Set **Spam check** to **Ikiform** and submit with the script on. The page falls back to a normal post and shows the check page.
5. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.

## Gotchas

- **`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.
- **Minified HTML.** Some build steps strip attributes they think are empty or unused. Check the built page still has `aria-hidden` and `tabindex` on the honeypot.
- **Cached thank-you pages.** If the thank-you page is the same URL for spam and real posts, which it should be, never put anything on it that depends on the post being real.
- **Keys in the repository.** A secret committed to a public static site's repository is public. Rotate it if it ever was.

## Questions

### Can I add a captcha to a static site?

You can show the widget, but its token must be verified with a secret key on a server. Use a function on your host, a form backend that verifies your keys, or Ikiform's managed check, which needs no keys.

### Is a honeypot enough for a static site?

It stops the most common bots, but only if something on a server reads it. Pair it with a rate limit and content scoring, which also need a server or a form backend.

### Why does my static form show 405 Method Not Allowed?

The form posts to a page on a host that only serves files. Point the form's action at a function or a form backend endpoint instead.

### Does the form work if JavaScript is off?

It should. Post with a plain HTML form first, and add a script that improves it and falls back to a normal post when anything goes wrong.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): 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.
- [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.
- [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.
- [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.
- [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.
