# Contact form: HTML, React and Next.js code

> A contact form for any website. Copy it as plain HTML, React or a Next.js server action, and let Ikiform validate, filter spam and email you.

Updated 2026-10-24 · https://ikiform.com/forms/contact-form

## Copy the code

Every version below is generated from the same field definition. Replace `frm_xxxxxxxxxxxxxxxx` with your form id, or enter it in the **Form ID** box and the code updates.

**HTML** (`contact-form.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" maxlength="120" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="topic">Topic</label>
  <select id="topic" name="topic">
    <option value="">Choose one</option>
    <option value="sales">Sales</option>
    <option value="support">Support</option>
    <option value="other">Something else</option>
  </select>
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" minlength="10" maxlength="5000" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send message</button>
</form>
```

**React** (`ContactForm.tsx`)

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

type Status = "idle" | "sending" | "sent" | "error";

export function ContactForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>Thanks. We'll reply by email.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required />
      {errors.name ? <p>{errors.name[0]}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic">
        <option value="">Choose one</option>
        <option value="sales">Sales</option>
        <option value="support">Support</option>
        <option value="other">Something else</option>
      </select>
      {errors.topic ? <p>{errors.topic[0]}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} minLength={10} maxLength={5000} required />
      {errors.message ? <p>{errors.message[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**JavaScript** (`contact-form.js`)

```js
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  name: "Name",
  email: "Email",
  topic: "Topic",
  message: "Message",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  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.replaceWith("Thanks. We'll reply by email.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});
```

- **HTML** needs no JavaScript. After a successful post the browser is redirected to the form's redirect URL, or to Ikiform's hosted thank-you page.
- **React** uses the SDK: `npm install @ikiform/sdk`. Field errors from the server appear under each field.
- **JavaScript** enhances the HTML form with `fetch`, so the visitor stays on the page.
- The hidden `_gotcha` input is the honeypot. Leave it empty and hidden.

> If you set the form's spam check to **Ikiform**, the HTML form keeps working with no change and the server action skips the check because it sends a secret key. The React and JavaScript versions must get a token before each submit: call `challenge(FORM_ID)` from `@ikiform/sdk` and pass it to `submit` as `turnstileToken`, or add it to the `fetch` body as `cf-turnstile-response`. Without it the post is refused with `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## The fields

Paste this definition on the form's **Fields** tab in the dashboard. Ikiform then checks every post against it on the server, refuses bad data with `422 validation_failed`, refuses any field that isn't listed, and stores typed values.

| Field id | Type | Required | Accepts |
| --- | --- | --- | --- |
| `name` | `text` | Yes | Up to 120 characters |
| `email` | `email` | Yes | One valid address |
| `topic` | `select` | No | One of: `sales`, `support`, `other` |
| `message` | `textarea` | Yes | 10 to 5,000 characters |

`Field definition`:

```json
{
  "version": 1,
  "title": "Contact",
  "fields": [
    {
      "id": "name",
      "type": "text",
      "label": "Name",
      "required": true,
      "validation": {
        "max_length": 120
      }
    },
    {
      "id": "email",
      "type": "email",
      "label": "Email",
      "required": true
    },
    {
      "id": "topic",
      "type": "select",
      "label": "Topic",
      "options": [
        {
          "value": "sales",
          "label": "Sales"
        },
        {
          "value": "support",
          "label": "Support"
        },
        {
          "value": "other",
          "label": "Something else"
        }
      ]
    },
    {
      "id": "message",
      "type": "textarea",
      "label": "Message",
      "required": true,
      "rows": 5,
      "validation": {
        "min_length": 10,
        "max_length": 5000
      }
    }
  ],
  "settings": {
    "submit_label": "Send message",
    "success_message": "Thanks. We'll reply by email."
  }
}
```

## Next.js server action

To keep the form working without JavaScript in a Next.js app, post from a server action with a secret key. Create the key under **API keys** with the `submissions:write` scope and keep it out of the browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/contact-form/actions.ts`:

```ts
"use server";

export interface ContactFormState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string[]>;
}

export async function sendContactForm(_previous: ContactFormState, formData: FormData): Promise<ContactFormState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks. We'll reply by email." };
  const values = Object.fromEntries([...new Set(body.keys())].map((name) => [name, body.getAll(name)]));
  return { ok: false, message: result.error.message, fields: result.error.fields, values };
}
```

`app/contact-form/form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type ContactFormState, sendContactForm } from "./actions";

const initial: ContactFormState = { ok: false, message: "" };

export function ContactForm() {
  const [state, action, pending] = useActionState(sendContactForm, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required defaultValue={state.values?.name?.[0]} />
      {state.fields?.name ? <p>{state.fields.name[0]}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email?.[0]} />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue={state.values?.topic?.[0]}>
        <option value="">Choose one</option>
        <option value="sales">Sales</option>
        <option value="support">Support</option>
        <option value="other">Something else</option>
      </select>
      {state.fields?.topic ? <p>{state.fields.topic[0]}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} minLength={10} maxLength={5000} required defaultValue={state.values?.message?.[0]} />
      {state.fields?.message ? <p>{state.fields.message[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

> A server action posts from your server's IP, so the per-visitor rate limit counts every visitor together. Use the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for forms you send through a server.

## Set it up

1. Create a form in the [dashboard](https://ikiform.com/dashboard) and copy its id.
2. Paste the field definition on the **Fields** tab.
3. Add your site under **Allowed domains**, plus `localhost` while you develop.
4. Set the rate limit to **Strict, 2 every 10 minutes per visitor** if visitors post from the browser. A real person rarely sends a contact form twice in ten minutes, so a repeat is usually a double click or a bot. With the server action, every post comes from your server's IP, so choose Relaxed or Custom instead.
5. Paste the code, send a test and check it arrives in the dashboard.

## Reply straight to the sender

Notification emails set their reply-to address from a `_replyto` field, or from a field named `email`, `email_address`, `your_email`, `reply_to`, `contact_email`. Keep the email field's id as `email` and pressing Reply in your inbox writes to the person who filled in the form.

Notification emails are capped at 50 per form per hour, or 10 while a workspace is younger than 7 days, then one hourly digest, so a sudden burst can't flood your inbox. They are on by default; change recipients or turn them off per form in the dashboard.

## What catches contact form spam

Contact forms get more spam than any other kind. Each post is scored, and at 5 or more it is stored as spam without an error, so bots learn nothing.

| Signal | Score |
| --- | --- |
| The `_gotcha` honeypot is filled in | +5 |
| A link in the `name` field | +3 |
| The same fields sent to this form in the last 24 hours | +3 |
| Three or more links in total | +2 |
| A known spam phrase such as `seo services` or `guest post` | +2 |
| An address on a disposable email domain | +2 |

Keep the field called `name`: a link in a name field is one of the strongest signals. If spam still gets through, set the form's spam check to **Ikiform** to add a Turnstile check. The HTML form needs no change for that, but the React and JavaScript versions must call `challenge(FORM_ID)` from `@ikiform/sdk` and pass the token to `submit` as `turnstileToken`, or they get `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## Routing by topic

A `select` only accepts the listed values, so `topic` always arrives as `sales`, `support` or `other`. That makes it safe to branch on in a webhook handler or an automation: support to a help desk channel, sales to the CRM.

## Where to send it

Every submission can go to a signed [webhook](https://ikiform.com/docs/webhooks.md). These guides cover the tools that suit this form, with the relay code:

- [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md)
- [Send form submissions to Discord](https://ikiform.com/blog/form-submissions-to-discord.md)
- [Send form submissions to Microsoft Teams](https://ikiform.com/blog/form-submissions-to-microsoft-teams.md)
- [Send form submissions to Google Chat](https://ikiform.com/blog/form-submissions-to-google-chat.md)
- [Send form submissions to Telegram](https://ikiform.com/blog/form-submissions-to-telegram.md)
- [Send form submissions to Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)

## Questions

### Where do contact form messages go?

Every message is stored in your Ikiform workspace. You can also get a notification email per message and send each one to Slack, Discord, a spreadsheet or your own server by signed webhook.

### Can I reply to the sender from the notification email?

Yes, if the email field is named `email`, `email_address`, `your_email`, `reply_to`, `contact_email` or the form sends a `_replyto` field. The notification's reply-to is then the sender's address.

### Do I need a captcha?

Not to start. The honeypot, spam scoring and rate limits run on every form. If spam gets through, switch the form's spam check to Ikiform, which runs Cloudflare Turnstile without a Cloudflare account. The HTML form keeps working as it is. The React and JavaScript versions must then call challenge(FORM_ID) from @ikiform/sdk before each submit and pass the token to submit as turnstileToken, or Ikiform refuses the post with 403 challenge_required.
