Forms

4 fields

Contact form

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.

Form ID

Preview

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

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.

<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>
  • 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 and Spam protection.

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 idTypeRequiredAccepts
nametextYesUp to 120 characters
emailemailYesOne valid address
topicselectNoOne of: sales, support, other
messagetextareaYes10 to 5,000 characters
Field definition
{
  "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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
app/contact-form/actions.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
"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 for forms you send through a server.

Set it up

  1. Create a form in the 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.

SignalScore
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 and Spam protection.

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. These guides cover the tools that suit this form, with the relay code:

Questions

Point your form at Ikiform