Contact form on a static site

Contact form on a static site6 min read

Add a contact form on Netlify without Netlify Forms

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.

Preet SutharFounder, Ikiform

Leave data-netlify off the form and point its action at your Ikiform endpoint; Netlify then serves the page and never touches the post. React and Vue forms need no hidden HTML copy, and can post JSON. Set a different form id for deploy previews in netlify.toml, because preview hosts don't match your site under Allowed domains.

What's different on Netlify

Netlify is the one major static host with a form feature built in. Netlify Forms reads your deployed HTML after each build, finds forms marked with data-netlify or netlify, and accepts posts to your own site for those forms. Every other post to a static page fails, and on Netlify it fails with 404, not 405:

Terminal
curl -i -X POST -d "message=hello" https://your-site.netlify.app/contact/
Response
HTTP/2 404
server: Netlify
cache-status: "Netlify Edge"; fwd=method; fwd-status=404
content-length: 0

That 404 sends people hunting for a missing page that isn't missing. It means no detected form matched the post. Whether to use Netlify Forms at all is a separate question, and the Ikiform vs Netlify Forms comparison answers it honestly: if your site will always live on Netlify and the form is plain HTML, Netlify Forms is less work. This page is for the other cases.

Your situationWhy Netlify Forms gets in the way
The form is rendered by React, Vue or SvelteThe build parses static HTML, so a client-rendered form needs a hidden duplicate for Netlify to find
You want to post JSON from fetchNetlify Forms takes urlencoded and multipart bodies only
The site may move to another hostThe form posts to your own domain, so it only works while Netlify serves that domain
Messages should reach a webhook you verifyIkiform signs each delivery with Standard Webhooks headers and retries it
Netlify Forms and Ikiform side by side on the same Netlify sitewith Netlify Formswith IkiformbuildNetlify scans theHTMLdata-netlify?POST to your siteNetlify stores it404 on POSTform ignoredbuildno scan neededPOST toikiform.comHTML or JSONIkiformstores, emails,webhooksfoundnot foundNetlify Forms and Ikiform side by side on the same Netlify sitewith Netlify Formswith IkiformbuildNetlify scans theHTMLdata-netlify?POST to yoursiteNetlify stores it404 onPOSTformignoredbuildno scan neededPOST toikiform.comHTML or JSONIkiformstores, emails,webhooksfoundnot found
  1. With Netlify Forms, Netlify scans each deploy's HTML for forms marked data-netlify.
  2. A form it found accepts posts to your own site, and Netlify stores them.
  3. A form it didn't find, such as one rendered by JavaScript without a hidden copy, gets 404 on every post.
  4. With Ikiform, nothing has to be found at build time.
  5. The form posts straight to ikiform.com, as HTML or JSON, and Ikiform stores it and emails you.
Both can live on one site. A form with data-netlify goes to Netlify; a form whose action is an Ikiform endpoint goes to Ikiform.

Take a form off Netlify Forms

  • Remove data-netlify or netlify, netlify-honeypot and data-netlify-recaptcha from the form, and any hidden form-name input. Left in, form-name is stored as an ordinary field.
  • Delete the hidden static copy of a JavaScript form, like the one below. Netlify only needed it to detect the form.
  • Set the action, or the fetch URL, to your Ikiform endpoint. The old action="/thanks" moves to the form's Redirect after submit setting.
  • Download past submissions from Netlify's Forms page first. They don't move on their own.
index.html, delete this
<form name="contact" netlify hidden>
  <input name="name" />
  <input name="email" />
  <textarea name="message"></textarea>
</form>

Leave form detection on if other forms on the site still use Netlify Forms. Netlify's docs say turning detection off is meant only for sites that don't use Netlify Forms. Once nothing uses it, turning it off saves a scan on every deploy.

What replaces Netlify's spam settings

Netlify runs Akismet on submissions and offers netlify-honeypot and reCAPTCHA. On Ikiform, the honeypot keeps working if you set the form's Honeypot field to the name you already use, such as bot-field. Spam scoring runs on every post with no setup, and Spam check set to Ikiform replaces reCAPTCHA with Cloudflare Turnstile and no keys. Remove the reCAPTCHA div when you switch.

A React form that posts JSON

Without Netlify Forms in the way, a client-rendered form doesn't need a duplicate, and it can send JSON, which keeps types intact and lets you trim values first. This one also records which Netlify context built the page:

src/ContactForm.tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = `https://ikiform.com/f/${import.meta.env.VITE_IKIFORM_FORM_ID}`;

interface Message {
  name: string;
  email: string;
  message: string;
  context: string;
}

export function ContactForm() {
  const [sent, setSent] = useState(false);
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const body: Message & Record<string, string> = {
      name: String(data.get("name") ?? "").trim(),
      email: String(data.get("email") ?? "").trim(),
      message: String(data.get("message") ?? "").trim(),
      context: import.meta.env.VITE_CONTEXT ?? "local",
      _gotcha: String(data.get("_gotcha") ?? ""),
    };
    setError("");
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) return setSent(true);
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    setError(Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.");
  }

  if (sent) return <p role="status">Got it. I'll answer from my own inbox, usually the same day.</p>;

  return (
    <form 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 />
      {error ? <p role="alert">{error}</p> : null}
      <button>Send</button>
    </form>
  );
}
  • Content-Type: application/json tells Ikiform to parse JSON; Accept: application/json asks for a JSON answer.
  • VITE_CONTEXT is an example name. Netlify sets CONTEXT during the build; expose it to Vite in your build command, or drop the field.
  • The honeypot is sent as a JSON key. Keep it in the body even though the input is hidden, so bots that fill it in still flag themselves.

Deploy previews get their own form

Deploy previews live at hosts like deploy-preview-12--your-site.netlify.app. That isn't a subdomain of your-site.netlify.app, so it doesn't match under Allowed domains, and preview posts land in spam. Give previews and branch deploys their own form in netlify.toml, and set the Content-Security-Policy header in the same file:

netlify.toml
[build]
  command = "npm run build"
  publish = "dist"

[context.production.environment]
  VITE_IKIFORM_FORM_ID = "frm_xxxxxxxxxxxxxxxx"

[context.deploy-preview.environment]
  VITE_IKIFORM_FORM_ID = "frm_yyyyyyyyyyyyyyyy"

[context.branch-deploy.environment]
  VITE_IKIFORM_FORM_ID = "frm_yyyyyyyyyyyyyyyy"

[[headers]]
  for = "/*"
  [headers.values]
    Content-Security-Policy = "default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com"

Leave the preview form's Allowed domains empty, or add the exact preview host while you test. The production form gets your domain and your-site.netlify.app. Headers in netlify.toml apply to every deploy context; if you prefer a _headers file, it has to end up in the publish directory.

Never add netlify.app on its own to Allowed domains. Every Netlify site would match, including copies of your form on other people's sites.

Check it works

  1. Open a deploy preview, submit, and confirm the message lands in the preview form.
  2. Merge, then submit on production. Confirm it lands in the production form and that Netlify's Forms page shows nothing new.
  3. 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.
  4. Post with the honeypot filled in, _gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
  5. Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.

Gotchas

ProblemWhat to do
404 on submitThe post went to your own site. A cached build may still have the old action, or a fetch call still posts to /. Search the publish directory for form-name.
Submissions still show up in NetlifySome form on the page still posts to your own site with data-netlify or netlify, so Netlify keeps detecting and storing it. Remove the attribute, set the action to Ikiform and redeploy.
404 not_found from Ikiform on previews onlyThe deploy preview context has no VITE_IKIFORM_FORM_ID, so the URL ends in undefined. Add it under [context.deploy-preview.environment].
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.
422 files_not_supportedThe form has an <input type="file"> with a file chosen. File uploads aren't supported yet; remove the input or ask for a link instead.

Questions

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