# Add a contact form to a static site without a backend

> Add a working contact form to a static site with no server: where the POST goes, spam, replies by email, and code for every host and framework.

Published 2026-10-25 · https://ikiform.com/guides/contact-form-static-site

A static host serves files and refuses a form POST, usually with `405` or `404`, so the form has to post somewhere else. Point the form's `action` at a form backend such as Ikiform, which stores each message, filters spam and emails you with the sender as the reply-to address. Your site stays static: no server code, no secret keys in the page and no change to how you deploy.

A static site is a folder of HTML, CSS and JavaScript that a host hands out as files. That's why it's fast, cheap and hard to break. It's also why the contact form is the first thing that stops working: a form submission is a request that something has to receive, and a static host has nothing listening.

## Why the form fails on a static host

A `<form method="post">` without an `action` posts to the page it's on. On a static host that's a file, and files only answer `GET` and `HEAD`. We sent a `POST` to a live static page on each major host on 2026-10-25. None of them stored anything:

| Host | What a POST to a static page gets |
| --- | --- |
| GitHub Pages | `405 Not Allowed`, as a small HTML page |
| Vercel | `405` with an empty body |
| Netlify | `404`, with the site's 404 page if it has one, unless Netlify Forms detected that form at deploy time |
| Cloudflare Pages | `405` with an empty body |
| Amazon S3 website endpoint | `405 Method Not Allowed`, code `MethodNotAllowed` |

The fix is the same everywhere: send the post to a URL that accepts it. That URL can be code you write and host, a feature of your host, or a form backend. The rest of this guide uses a form backend, because it keeps the site static.

Diagram: Where a contact form post goes, before and after the fix

1. With no action, or an action that points at a path on your own site, the browser posts to the static host.
2. The host has no code to run for a POST, so it answers 405 or 404 and the message is gone.
3. With the action pointing at your Ikiform endpoint, the post goes to a server that accepts it.
4. Ikiform checks the message for spam, stores it and emails you.
5. Ikiform answers with a 303 redirect, and the browser loads the thank-you page on your own site.

Only the form's action changes. The page, the host and the deploy stay as they are.

## What happens to each message

Ikiform is the endpoint the form posts to. It doesn't host your page or render your form; you keep both. For each post it runs the spam checks, stores the message, and fans it out to the places you read it.

Diagram: What Ikiform does with one contact form message

1. A visitor fills in the contact form and it posts to your Ikiform endpoint.
2. Ikiform checks the honeypot, the rate limit and the spam score, then stores the message.
3. Each notification recipient gets an email, with reply-to set to the sender's address.
4. The message shows up in the dashboard, where you can search it, restore spam and export CSV.
5. If you add a webhook, Ikiform sends a signed copy to Slack, Discord, an automation tool or your own code.

Email is enough for most contact forms. Add a webhook when messages should land where you already work.

## Five ways to receive a static site's form

| Approach | Good at | Trade-offs |
| --- | --- | --- |
| A `mailto:` link or `action="mailto:..."` | Zero setup. Fine for a personal page with little traffic. | Opens the visitor's mail app, if they have one set up. Nothing is stored or filtered, and your address is in the HTML for scrapers. |
| Your host's form feature, such as [Netlify Forms](https://ikiform.com/alternatives/netlify-forms) | One attribute, no other account, and usually included in the hosting plan. | Works only on that host. Moving hosts means rebuilding the form, and forms rendered by JavaScript need workarounds. |
| A serverless function you write | Full control over validation, storage and the email you send. | You now own a backend: an email API key, spam filtering, rate limits, retries and logs. Each host's function format is different. |
| A hosted form embed, such as [Google Forms](https://ikiform.com/alternatives/google-forms), [Tally](https://ikiform.com/alternatives/tally) or [Typeform](https://ikiform.com/alternatives/typeform) | No code. Good for long or multi-step forms. | An iframe or a link to another site, with their look and their scripts on your page. |
| A form backend, such as Ikiform, [Formspree](https://ikiform.com/alternatives/formspree), [Basin](https://ikiform.com/alternatives/basin) or [Web3Forms](https://ikiform.com/alternatives/web3forms) | Your own HTML on any host. Storage, spam filtering and email with no server. | Another account and another bill, and the form data lives with that service. |

If you're on Netlify and never plan to leave, Netlify Forms is hard to beat on effort. If you want to write the backend, a function is a fine weekend project. If you want the form to keep working when you change hosts or frameworks, use a form backend. The [comparisons](https://ikiform.com/alternatives) set the backends side by side.

## The form

Every sample below posts from the browser, because a static deploy has no server to post from. Pick your stack; the choice carries over to every code sample in these guides. Static site generators get their own setup pages: [Jekyll](https://ikiform.com/docs/guides/jekyll.md), [Hugo](https://ikiform.com/docs/guides/hugo.md), [Eleventy](https://ikiform.com/docs/guides/eleventy.md) and [Gatsby](https://ikiform.com/docs/guides/gatsby.md).

**HTML**

This is the whole integration for a hand-written site. The form posts the plain way, so it works with JavaScript off, and the browser lands on your `thanks.html` after a successful send.

`contact.html`:

```html
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" maxlength="120" required />
  <p class="field-error" data-error-for="name" hidden></p>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <p class="field-error" data-error-for="email" hidden></p>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
  <p class="field-error" data-error-for="message" hidden></p>

  <input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send message</button>
  <p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>

<script src="/contact.js" defer></script>
```

The script is optional. It keeps the visitor on the page, puts each field error under its input, and leaves the typed message in place when something fails.

`contact.js`:

```js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");
const done = document.querySelector("#contact-sent");
const button = form.querySelector("button");

function showErrors(fields = {}) {
  for (const slot of form.querySelectorAll("[data-error-for]")) {
    const name = slot.dataset.errorFor;
    const message = fields[name]?.[0] ?? "";
    slot.textContent = message;
    slot.hidden = !message;
    form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  button.disabled = true;
  status.textContent = "Sending";
  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.hidden = true;
      done.hidden = false;
      return;
    }
    showErrors(result.error.fields);
    status.textContent = result.error.message;
  } catch {
    status.textContent = "Couldn't send. Check your connection and try again.";
  }
  button.disabled = false;
});
```

**Next.js**

With `output: "export"`, `next build` writes plain files to `out/`. Server Actions and Route Handlers that read the request are not supported in a static export, so the form posts from the browser instead.

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;
```

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

```tsx
"use client";

import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;

export function ContactForm() {
  const [sentTo, setSentTo] = useState("");
  const [sending, setSending] = useState(false);
  const [errors, setErrors] = useState<Errors>({});
  const [notice, setNotice] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setSending(true);
    setNotice("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) {
        setSentTo(String(body.get("email")));
        return;
      }
      const fields = result.error.fields ?? {};
      setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
      setNotice(result.error.message);
    } catch {
      setNotice("Couldn't send. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
      {errors.name ? <p>{errors.name}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
      {errors.email ? <p>{errors.email}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
      {errors.message ? <p>{errors.message}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {notice ? <p role="alert">{notice}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

Render `<ContactForm />` from `app/contact/page.tsx`. The form keeps its `action`, so it still posts before the JavaScript bundle loads.

**Astro**

Astro prerenders every page by default, which is what a static host wants. Don't reach for Astro Actions here: posting a form to an action needs on-demand rendering and an adapter. Post to Ikiform, and let the bundled `<script>` keep visitors on the page.

`src/pages/contact.astro`:

```astro
---
import Layout from "../layouts/Layout.astro";

const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---

<Layout title="Contact">
  <form id="contact" action={endpoint} method="post">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required />
    <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="6" minlength="10" required></textarea>
    <input type="hidden" name="_redirect" value={new URL("/thanks/", Astro.site).href} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send message</button>
    <output id="contact-status" role="status"></output>
  </form>
</Layout>

<script>
  const form = document.querySelector<HTMLFormElement>("#contact")!;
  const status = document.querySelector<HTMLOutputElement>("#contact-status")!;

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    status.value = "Sending";
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) {
      form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Thanks. Your message is on its way, and I'll reply by email." }));
      return;
    }
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.entries(fields)[0];
    status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
  });
</script>
```

`Astro.site` comes from `site` in `astro.config.mjs`. Set it to your `https:` address so the `_redirect` value is a full URL Ikiform can follow.

**TanStack Start**

SPA mode builds a client-rendered shell that any static host can serve. Server functions still need a server, so a static deploy can't run them; the route posts from the browser and moves to `/thanks` on success.

`vite.config.ts`:

```ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    tanstackStart({
      spa: {
        enabled: true,
      },
    }),
    viteReact(),
  ],
});
```

`src/routes/contact.tsx`:

```tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

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

function Contact() {
  const navigate = useNavigate();
  const [problems, setProblems] = useState<string[]>([]);
  const [sending, setSending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) {
        await navigate({ to: "/thanks" });
        return;
      }
      const fields: Record<string, string[]> = result.error.fields ?? {};
      setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
    } catch {
      setProblems(["Couldn't send. Check your connection and try again."]);
    }
    setSending(false);
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      {problems.length ? (
        <ul role="alert">
          {problems.map((problem) => (
            <li key={problem}>{problem}</li>
          ))}
        </ul>
      ) : null}
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <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={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**React Router**

With `ssr: false` there is no server build, so a route can't have a server `action`. A `clientAction` runs in the browser instead: it forwards the `<Form>` post to Ikiform and redirects on success.

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;
```

`app/routes/contact.tsx`:

```tsx
import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export async function clientAction({ request }: Route.ClientActionArgs) {
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: await request.formData(),
  });
  const result = await response.json();
  if (result.ok) return redirect("/thanks");
  return {
    fields: (result.error.fields ?? {}) as Record<string, string[]>,
    message: String(result.error.message),
  };
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";
  const error = (name: string) => actionData?.fields[name]?.[0];

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name") ? <p>{error("name")}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email") ? <p>{error("email")}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      {error("message") ? <p>{error("message")}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

**SvelteKit**

With `adapter-static` and `prerender = true`, every page is a file. SvelteKit form actions are out: pages with actions can't be prerendered, because a server has to handle their POST. The component posts with `fetch` instead.

`src/routes/+layout.ts`:

```ts
export const prerender = true;
```

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

```svelte
<script lang="ts">
  const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

  let phase = $state<"idle" | "sending" | "sent">("idle");
  let errors = $state<Record<string, string[]>>({});
  let notice = $state("");

  async function send(event: SubmitEvent) {
    event.preventDefault();
    phase = "sending";
    notice = "";
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget as HTMLFormElement),
      });
      const result = await response.json();
      if (result.ok) {
        phase = "sent";
        return;
      }
      errors = result.error.fields ?? {};
      notice = result.error.message;
    } catch {
      notice = "Couldn't send. Check your connection and try again.";
    }
    phase = "idle";
  }
</script>

{#if phase === "sent"}
  <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
  <form action={ENDPOINT} method="post" onsubmit={send}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
    {#if errors.name}<p>{errors.name[0]}</p>{/if}
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
    {#if errors.email}<p>{errors.email[0]}</p>{/if}
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
    {#if errors.message}<p>{errors.message[0]}</p>{/if}
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if notice}<p role="alert">{notice}</p>{/if}
    <button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

**Nuxt**

`nuxt generate` writes prerendered pages to `.output/public`. Routes in `server/api` don't exist on a static host, so the component calls Ikiform directly with `$fetch` and moves to `/thanks` on success.

`app/pages/contact.vue`:

```vue
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);

async function send() {
  sending.value = true;
  notice.value = "";
  try {
    await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
    await navigateTo("/thanks");
  } catch (failure) {
    const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
    errors.value = data?.error?.fields ?? {};
    notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="form.name" name="name" autocomplete="name" required />
    <p v-if="errors.name">{{ errors.name[0] }}</p>
    <label for="email">Email</label>
    <input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email">{{ errors.email[0] }}</p>
    <label for="message">Message</label>
    <textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
    <p v-if="errors.message">{{ errors.message[0] }}</p>
    <input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="notice" role="alert">{{ notice }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

**React**

A Vite single-page app on a static host. The endpoint sits in a `VITE_` variable so preview and production builds can point at different forms; it ends up in the bundle either way, which is fine, because it isn't a secret.

`.env.production`:

```text
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx
```

`src/ContactForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };

export function ContactForm() {
  const [status, setStatus] = useState<Status>({ kind: "idle" });

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setStatus({ kind: "sending" });
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) return setStatus({ kind: "sent" });
      const fields: Record<string, string[]> = result.error.fields ?? {};
      const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
      setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
    } catch {
      setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
    }
  }

  if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <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={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status.kind === "failed" ? (
        <div role="alert">
          {status.lines.map((line) => (
            <p key={line}>{line}</p>
          ))}
        </div>
      ) : null}
      <button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

The same browser post in a Vue single-file component, reading the endpoint from the same `VITE_` variable. Only the first error is shown, next to the button.

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { computed, ref } from "vue";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);

async function send(event: SubmitEvent) {
  sending.value = true;
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    sent.value = result.ok;
    fields.value = result.ok ? {} : (result.error.fields ?? {});
    notice.value = result.ok ? "" : result.error.message;
  } catch {
    notice.value = "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="firstError" role="alert">{{ firstError }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- Replace `frm_xxxxxxxxxxxxxxxx` with your form's id from the dashboard. The id is public by design: it's in your HTML the moment you deploy. Never put a secret API key in a static site.
- Keep the email field's name as `email`. Ikiform sets it as the reply-to address of notification emails and checks it against disposable email domains.
- The hidden `_gotcha` input is the honeypot. People never see it; bots that fill in every input mark themselves as spam.
- The JavaScript versions send `Accept: application/json`, so Ikiform answers `201` on success, or `422` with an `error.fields` object that names each field and the problem.

If your framework does run on a server in production, you can post from there instead and keep the endpoint out of the page. The [Next.js](https://ikiform.com/docs/guides/nextjs.md), [SvelteKit](https://ikiform.com/docs/guides/sveltekit.md) and [Nuxt](https://ikiform.com/docs/guides/nuxt.md) guides show the server versions, and [From a server](https://ikiform.com/docs/guides/server.md) covers secret keys.

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

## Fields for a contact form

Name, email and message cover almost every contact form. A topic select is worth adding when different people answer different kinds of message. Try the form below: it runs Ikiform's validator in your browser and shows what would be stored, or the `422` you'd get back.

Form: Contact

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

The same definition as the [contact form template](https://ikiform.com/forms/contact-form). Paste it on the form's **Fields** tab and Ikiform enforces it on the server, whatever the HTML says.

| Field | Why | Keep in mind |
| --- | --- | --- |
| `name` | How you greet them in the reply | A link in a name field is a strong spam signal, so keep the id `name` |
| `email` | Where the reply goes | Required. `type="email"` brings up the right phone keyboard |
| `topic` | Send sales and support to the right person | A select only accepts the listed values, so it's safe to filter on |
| `message` | The point of the form | A minimum length of 10 stops one-word posts |

Leave out phone numbers, company size and anything else you won't use in your first reply. Every field you add costs messages. Without a field definition, Ikiform stores whatever named inputs the form sends; with one, it rejects posts that don't match.

> File uploads aren't supported yet. A post with a file gets `422 files_not_supported`.

## Reply from your inbox

Notification emails go to up to 10 confirmed recipients per form. When the form has an `email` field, the email's reply-to is the sender, so **Reply** in your mail app writes straight back to them. You never copy an address out of a dashboard.

Notifications are capped at 50 emails per form per hour, then one hourly digest, so a spam wave can't bury your inbox. Messages marked as spam send no email at all. `_subject` doesn't change the subject of notification emails. It is passed on to webhooks and the API as `meta.subject`.

> Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a [webhook](https://ikiform.com/docs/webhooks.md).

Want messages in a team chat instead of, or as well as, email? Add a webhook and follow [Slack](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md) or [Telegram](https://ikiform.com/blog/form-submissions-to-telegram.md).

## What the visitor sees after sending

A plain HTML post ends in a redirect. Ikiform answers a successful browser post with `303 See Other`, and the browser loads the target with a `GET`, which your static host is happy to serve.

Diagram: The three requests behind one plain HTML form submission

1. The browser posts the form fields to your Ikiform endpoint.
2. Ikiform stores the message and answers 303 See Other with a Location header.
3. The browser follows the redirect with a GET to your thank-you page, which the static host serves like any other file.

Without a redirect target, step 3 loads Ikiform's hosted thank-you page instead of yours.

Ikiform picks the target in this order:

- **Redirect after submit** in the form's settings. Any `http:` or `https:` URL works, because only your team can set it. This is the simplest choice for a single site.
- A `_redirect` field in the form, if it's an `https:` URL on one of the form's **Allowed domains**. Use it when one form serves several sites or languages.
- Otherwise, Ikiform's hosted thank-you page, which links back to your site when the post came from an allowed `https:` page.

Make the thank-you page a real file and keep it out of search results. Say when you'll reply; it saves you a second email from the same person.

`thanks.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="robots" content="noindex" />
    <title>Message sent</title>
  </head>
  <body>
    <h1>Thanks, your message is on its way</h1>
    <p>I read every message and reply within two working days, from the address on this site.</p>
    <p><a href="/">Back to the home page</a></p>
  </body>
</html>
```

The JavaScript versions above skip the redirect. A post with `Accept: application/json` gets JSON back, and the page shows the success line itself.

## Spam on a static form

Your endpoint is in your page source, and bots read page source. Expect spam within days of going live. Ikiform layers its defences so you can start with none of the setup:

| Defence | What it stops | Setup |
| --- | --- | --- |
| Honeypot | Bots that fill in every input | The hidden `_gotcha` input |
| Spam scoring | Links in the name (+3), many links (+2), known spam phrases (+2) and disposable addresses. At 5 a message is stored as spam | None |
| Rate limits | Scripts posting in a loop | On by default: Standard, 5 a minute per visitor |
| Allowed domains | Copies of your form posting from other sites | Add your domains in the form's settings |
| Human check | Bots driving a real browser | Set **Spam check** to **Ikiform** for Cloudflare Turnstile with no keys |

Spam gets the same success answer as a real message, so a bot learns nothing, and it sends no email or webhook. A contact form rarely needs more than one post from a person in a few minutes, so Strict, 2 every 10 minutes per visitor suits it once you're live. The [spam docs](https://ikiform.com/docs/spam.md) cover each layer, and the human check needs one extra call in the JavaScript versions: `challenge()` from the [SDK](https://ikiform.com/docs/sdk.md).

### Allowed domains and preview deploys

**Allowed domains** matches a domain and its subdomains. Static hosts give you more domains than you think: the production domain, the host's own subdomain, and a preview address per branch or pull request. Some previews are subdomains of your site's host address and match it; others aren't and need their own entry.

| Host | Add | Previews |
| --- | --- | --- |
| GitHub Pages | `username.github.io` and your custom domain | No previews by default |
| Vercel | `your-project.vercel.app` and your domain | Branch URLs don't match; add the exact host while you test |
| Netlify | `your-site.netlify.app` and your domain | `deploy-preview-12--your-site.netlify.app` doesn't match |
| Cloudflare Pages | `your-project.pages.dev` and your domain | `abc123.your-project.pages.dev` already matches |

Never add a shared host on its own, such as `github.io`, `vercel.app` or `netlify.app`. Every site on that host would match, including other people's.

## If your site sends a Content-Security-Policy

Many static templates ship a strict `Content-Security-Policy`. Two directives decide whether the form works: `form-action` for the plain HTML post, and `connect-src` for the `fetch` versions. Leave either one out and the browser blocks the request before it leaves the page, with only a console message to show for it.

`Response header`:

```text
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

Keep `'self'` in `form-action`: some browsers also check the redirect back to your thank-you page against it. Where the header lives depends on the host: `_headers` on Netlify and Cloudflare Pages, `vercel.json` on Vercel, a `<meta>` tag on GitHub Pages, and a CloudFront response headers policy in front of S3. The host pages below show each one.

## Test before you deploy

Open the page through a local server, not by double-clicking the file. A page opened as `file://` sends `Origin: null`, which matches no allowed domain and doesn't count as a local test. Any of these serves the current folder:

`Terminal`:

```bash
npx serve .

python3 -m http.server 8000
```

Add `localhost` to **Allowed domains** while you test. Posts from `localhost` count as tests: a workspace without a plan gets 50 a month, so you can build the whole form before you pay.

## Pick your situation

The form above works on every static host. Each host has its own way of failing, and its own preview domains and header files. These pages cover them.

- [GitHub Pages](https://ikiform.com/guides/contact-form-static-site/github-pages.md): GitHub Pages can't run server code, so a form POST gets 405 Not Allowed; post to a form backend instead and get project-site redirects right.
- [Vercel](https://ikiform.com/guides/contact-form-static-site/vercel.md): A static page on Vercel answers a form POST with 405 Method Not Allowed; here is why, and how to send the form to a backend instead.
- [Netlify](https://ikiform.com/guides/contact-form-static-site/netlify.md): Run a contact form on a Netlify site without Netlify Forms: no build-time detection, JSON posts from React, and a separate form for deploy previews.
- [Cloudflare Pages](https://ikiform.com/guides/contact-form-static-site/cloudflare-pages.md): Add a contact form to Cloudflare Pages without writing a Function: post to a form backend, set headers in _headers and use your own Turnstile keys.
- [Amazon S3](https://ikiform.com/guides/contact-form-static-site/aws-s3.md): S3 website endpoints accept only GET and HEAD over plain HTTP, so post the contact form to a form backend and get redirects right with CloudFront.
- [Portfolio website](https://ikiform.com/guides/contact-form-static-site/portfolio-website.md): A portfolio contact form that asks the questions you'd ask on a first call: project, budget, timing and a link, with no server and replies from your inbox.

## Check it works

Run through this once on your live domain and once on a preview, if your host makes them. 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. Submit from your local server and confirm the message appears under **Submissions**.
3. Deploy, then submit from the live site with JavaScript turned off. You should land on your own thank-you page.
4. Turn JavaScript back on and submit with an empty message. The error should appear on the page, not as a hosted error page.
5. Check the inbox of each notification recipient. Each gets an email with the fields, and **Reply** goes to the submitter when the form has an `email` field.
6. 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.
7. 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 |
| --- | --- |
| `405 Method Not Allowed` or `404` right after pressing Send | The form posted to your own site. Check the `action` attribute in the deployed HTML, not your source; a build step or a framework `<Form>` component may have replaced it. |
| Nothing happens, and the console mentions `Content-Security-Policy` | Add `https://ikiform.com` to `form-action` for plain posts and to `connect-src` for `fetch`. |
| `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. |
| Local tests are refused with `402 plan_required` | The page was opened as a `file://` URL, so the post had no usable origin and counted as live. Serve the folder with a local server and test from `localhost`. |
| The browser lands on Ikiform's thank-you page instead of yours | `_redirect` is ignored unless its domain is in **Allowed domains** and it is an `https:` URL. Add the domain, or set **Redirect after submit** in the form's settings instead. |
| `422 validation_failed` | The post doesn't match the form's field definition. `error.fields` names each field and the reason. Match the input `name` to the field id. |
| 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. |
| `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. |

## Questions

### Can a static website have a working contact form?

Yes. The page stays static; only the form's action points at a service that accepts posts. A form backend stores each message, filters spam and emails you, so you never run a server.

### Is it safe to put the form endpoint in public HTML?

Yes. A form id is meant to be public, the same way an email address on a contact page is. Protect the form with allowed domains, the honeypot, rate limits and the human check. What must never be in a static site is a secret API key.

### Why not use a mailto: link?

It only works when the visitor has a mail app set up, it stores nothing, and it puts your address in the page for scrapers. A form keeps the visitor on your site and gives you a record of every message.

### Can I use PHP's mail() on GitHub Pages or Netlify?

No. Static hosts don't run PHP or any other server code for your pages. A PHP mail script needs a server, which is the thing a static site avoids.

### Can the form accept file uploads?

Not yet. A post that contains a file is refused with 422 files_not_supported. Ask for a link to the file instead, such as a shared drive URL.

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

- [GitHub Pages](https://ikiform.com/guides/contact-form-static-site/github-pages.md): GitHub Pages can't run server code, so a form POST gets 405 Not Allowed; post to a form backend instead and get project-site redirects right.
- [Vercel](https://ikiform.com/guides/contact-form-static-site/vercel.md): A static page on Vercel answers a form POST with 405 Method Not Allowed; here is why, and how to send the form to a backend instead.
- [Netlify](https://ikiform.com/guides/contact-form-static-site/netlify.md): Run a contact form on a Netlify site without Netlify Forms: no build-time detection, JSON posts from React, and a separate form for deploy previews.
- [Cloudflare Pages](https://ikiform.com/guides/contact-form-static-site/cloudflare-pages.md): Add a contact form to Cloudflare Pages without writing a Function: post to a form backend, set headers in _headers and use your own Turnstile keys.
- [Amazon S3](https://ikiform.com/guides/contact-form-static-site/aws-s3.md): S3 website endpoints accept only GET and HEAD over plain HTTP, so post the contact form to a form backend and get redirects right with CloudFront.
- [Portfolio website](https://ikiform.com/guides/contact-form-static-site/portfolio-website.md): A portfolio contact form that asks the questions you'd ask on a first call: project, budget, timing and a link, with no server and replies from your inbox.
- [Send a form to your email](https://ikiform.com/guides/send-form-to-email.md): Get every form submission in your inbox without a mail server: why mailto: and PHP mail() fail, and a setup that filters spam and lets you reply.
- [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.
- [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.
