Guides

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.

Preet SutharFounder, Ikiform

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:

HostWhat a POST to a static page gets
GitHub Pages405 Not Allowed, as a small HTML page
Vercel405 with an empty body
Netlify404, with the site's 404 page if it has one, unless Netlify Forms detected that form at deploy time
Cloudflare Pages405 with an empty body
Amazon S3 website endpoint405 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.

Where a contact form post goes, before and after the fixaction points at your own siteaction points at Ikiformcontact pagestatic HTMLstatic hostserves files, answersGET405 or 404message lostcontact pagesame HTML, new actionIkiformchecks, stores, emailsthank-you pageback on your sitePOSTPOST303Where a contact form post goes, before and after the fixaction points at your own siteaction points at Ikiformcontact pagestatic HTMLstatic hostserves files, answers GET405 or 404message lostcontact pagesame HTML, new actionIkiformchecks, stores, emailsthank-you pageback on your sitePOSTPOST303
  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.

What Ikiform does with one contact form messagecontact formon any static hostIkiformspam checks, then storesnotification emailReply goes to the senderdashboardsearch, spam filter, CSVsigned webhookSlack, Discord, your codepostWhat Ikiform does with one contact form messagecontactformon any statichostIkiformspam checks,then storesnotificationemailReply goes to thesenderdashboardsearch, spam filter,CSVsigned webhookSlack, Discord,your codepost
  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

ApproachGood atTrade-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 FormsOne 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 writeFull 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 TypeformNo 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 Web3FormsYour 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.

contact.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
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;
});
  • 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, SvelteKit and Nuxt guides show the server versions, and From a server 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 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.

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

The same definition as the contact form template. Paste it on the form's Fields tab and Ikiform enforces it on the server, whatever the HTML says.
FieldWhyKeep in mind
nameHow you greet them in the replyA link in a name field is a strong spam signal, so keep the id name
emailWhere the reply goesRequired. type="email" brings up the right phone keyboard
topicSend sales and support to the right personA select only accepts the listed values, so it's safe to filter on
messageThe point of the formA 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.

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 three requests behind one plain HTML form submissionbrowservisitor pressesSendikiform.com/f/…stores the messageyour/thanks.htmla static filePOST303, then GETThe three requests behind one plain HTML form submissionbrowservisitor presses Sendikiform.com/f/…stores the messageyour /thanks.htmla static filePOST303, then GET
  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
<!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:

DefenceWhat it stopsSetup
HoneypotBots that fill in every inputThe hidden _gotcha input
Spam scoringLinks in the name (+3), many links (+2), known spam phrases (+2) and disposable addresses. At 5 a message is stored as spamNone
Rate limitsScripts posting in a loopOn by default: Standard, 5 a minute per visitor
Allowed domainsCopies of your form posting from other sitesAdd your domains in the form's settings
Human checkBots driving a real browserSet 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.

HostAddPreviews
GitHub Pagesusername.github.io and your custom domainNo previews by default
Vercelyour-project.vercel.app and your domainBranch URLs don't match; add the exact host while you test
Netlifyyour-site.netlify.app and your domaindeploy-preview-12--your-site.netlify.app doesn't match
Cloudflare Pagesyour-project.pages.dev and your domainabc123.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
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
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.

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.

  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

ProblemWhat to do
405 Method Not Allowed or 404 right after pressing SendThe 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-PolicyAdd https://ikiform.com to form-action for plain posts and to connect-src for fetch.
403 origin_not_allowed, or every post lands in spamThe 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_requiredThe 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_failedThe 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 arrivedIt 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_limitedOne 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.