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.
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
_gotchainput is the honeypot. Leave it empty and hidden.
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 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 |
{
"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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key"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 };
}"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>
);
}Set it up
- Create a form in the dashboard and copy its id.
- Paste the field definition on the Fields tab.
- Add your site under Allowed domains, plus
localhostwhile you develop. - 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.
- 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 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:
- Send form submissions to Slack
- Send form submissions to Discord
- Send form submissions to Microsoft Teams
- Send form submissions to Google Chat
- Send form submissions to Telegram
- Send form submissions to Zapier
Questions
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.
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.
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.