Guides15 min read
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.
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.
- With no action, or an action that points at a path on your own site, the browser posts to the static host.
- The host has no code to run for a POST, so it answers 405 or 404 and the message is gone.
- With the action pointing at your Ikiform endpoint, the post goes to a server that accepts it.
- Ikiform checks the message for spam, stores it and emails you.
- Ikiform answers with a 303 redirect, and the browser loads the thank-you page on your own site.
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.
- A visitor fills in the contact form and it posts to your Ikiform endpoint.
- Ikiform checks the honeypot, the rate limit and the spam score, then stores the message.
- Each notification recipient gets an email, with reply-to set to the sender's address.
- The message shows up in the dashboard, where you can search it, restore spam and export CSV.
- If you add a webhook, Ikiform sends a signed copy to Slack, Discord, an automation tool or your own code.
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 | 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, Tally or 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, Basin or 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 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, Hugo, Eleventy and Gatsby.
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.
<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.
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;
});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.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"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 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.
---
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.
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.
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(),
],
});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>
);
}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.
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;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>
);
}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.
export const prerender = true;<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 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.
<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>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.
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { 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>
);
}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.
<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_xxxxxxxxxxxxxxxxwith 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
_gotchainput 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 answers201on success, or422with anerror.fieldsobject 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, SvelteKit and Nuxt guides show the server versions, and From a server covers secret keys.
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.
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
| 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.
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.
Want messages in a team chat instead of, or as well as, email? Add a webhook and follow Slack, Discord or Telegram.
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.
- The browser posts the form fields to your Ikiform endpoint.
- Ikiform stores the message and answers 303 See Other with a Location header.
- The browser follows the redirect with a GET to your thank-you page, which the static host serves like any other file.
Ikiform picks the target in this order:
- Redirect after submit in the form's settings. Any
http:orhttps:URL works, because only your team can set it. This is the simplest choice for a single site. - A
_redirectfield in the form, if it's anhttps: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.
<!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 cover each layer, and the human check needs one extra call in the JavaScript versions: challenge() from the SDK.
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.
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comKeep '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:
npx serve .
python3 -m http.server 8000Add 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 PagesGitHub 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.
- VercelA 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.
- NetlifyRun 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 PagesAdd 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 S3S3 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 websiteA 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.
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Submit from your local server and confirm the message appears under Submissions.
- Deploy, then submit from the live site with JavaScript turned off. You should land on your own thank-you page.
- Turn JavaScript back on and submit with an empty message. The error should appear on the page, not as a hosted error page.
- 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
emailfield. - 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. - 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
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.
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.
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.
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.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.