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

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

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`:

```bash
curl -i -X POST -d "message=hello" https://your-site.netlify.app/contact/
```

`Response`:

```http
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](https://ikiform.com/alternatives/netlify-forms) 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 |

Diagram: Netlify Forms and Ikiform side by side on the same Netlify site

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`:

```html
<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`:

```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`:

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

| 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

### Can I use a different form backend while my site stays on Netlify?

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.

### Why does my form get 404 on Netlify?

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.

### Can I keep Netlify Forms for one form and use Ikiform for another?

Yes. Detection is per form, by attribute. Keep data-netlify on the forms Netlify should handle and leave form detection on.

### Do I lose my old Netlify submissions when I switch?

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.

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

## More guides

- [Contact form on a static site](https://ikiform.com/guides/contact-form-static-site.md): 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.
- [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.
- [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.
- [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.
- [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.
