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.
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:
curl -i -X POST -d "message=hello" https://your-site.netlify.app/contact/HTTP/2 404
server: Netlify
cache-status: "Netlify Edge"; fwd=method; fwd-status=404
content-length: 0That 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 situation | Why Netlify Forms gets in the way |
|---|---|
| The form is rendered by React, Vue or Svelte | The build parses static HTML, so a client-rendered form needs a hidden duplicate for Netlify to find |
You want to post JSON from fetch | Netlify Forms takes urlencoded and multipart bodies only |
| The site may move to another host | The form posts to your own domain, so it only works while Netlify serves that domain |
| Messages should reach a webhook you verify | Ikiform signs each delivery with Standard Webhooks headers and retries it |
- With Netlify Forms, Netlify scans each deploy's HTML for forms marked data-netlify.
- A form it found accepts posts to your own site, and Netlify stores them.
- A form it didn't find, such as one rendered by JavaScript without a hidden copy, gets 404 on every post.
- With Ikiform, nothing has to be found at build time.
- The form posts straight to ikiform.com, as HTML or JSON, and Ikiform stores it and emails you.
Take a form off Netlify Forms
- Remove
data-netlifyornetlify,netlify-honeypotanddata-netlify-recaptchafrom the form, and any hiddenform-nameinput. Left in,form-nameis 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 thefetchURL, to your Ikiform endpoint. The oldaction="/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.
<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:
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/jsontells Ikiform to parse JSON;Accept: application/jsonasks for a JSON answer.VITE_CONTEXTis an example name. Netlify setsCONTEXTduring 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:
[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.
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
- Open a deploy preview, submit, and confirm the message lands in the preview form.
- Merge, then submit on production. Confirm it lands in the production form and that Netlify's Forms page shows nothing new.
- 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.
Gotchas
| Problem | What to do |
|---|---|
404 on submit | The 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 Netlify | Some 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 only | The 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 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. |
422 files_not_supported | The 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
Yes. Netlify only handles forms it detected at build time. A form without data-netlify whose action points at another endpoint is served by Netlify like any page and posts straight to that endpoint.
Netlify answers a POST to a static page with 404 unless Netlify Forms detected that form in the deployed HTML. Either mark the form for Netlify Forms or point its action at another backend.
Yes. Detection is per form, by attribute. Keep data-netlify on the forms Netlify should handle and leave form detection on.
They stay in Netlify, not in Ikiform. Download them from the site's Forms page before you turn form detection off, and keep the file with your records.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.