Guides14 min read
Send HTML form submissions to your email
Get every form submission in your inbox without a mail server: why mailto: and PHP mail() fail, and a setup that filters spam and lets you reply.
An HTML form can't send email by itself, because a browser has no way to talk to a mail server. Point the form's action at a form backend, which receives the post, filters spam, stores it and emails you and your team. Name the email field email and pressing Reply in your inbox writes straight back to the person who filled in the form.
Most sites need one thing from a contact form: the message in an inbox someone reads. That turns out to be harder than it looks. HTML has a <form> element but no way to send mail, and the old shortcuts either hand the job back to the visitor or land in spam. This guide explains why, compares the options honestly, and sets up a form that emails you on any site and any stack.
Why a form can't send email on its own
When a visitor presses Send, the browser makes one HTTP request to the URL in the form's action. That's all a form can do. Sending email needs an SMTP conversation with a mail server, from a machine the receiving server trusts. A browser can't open that connection, and even if it could, mail from a random visitor's laptop would be rejected. So something on a server always sits between the form and your inbox: your own code, a script on your host, or a service.
With a form backend, that something is a URL. The form posts to https://ikiform.com/f/<id>, and Ikiform does the rest:
- The visitor submits your form, and the browser or your server posts it to your Ikiform endpoint.
- Ikiform checks the fields against the form's definition, scores the post for spam, and stores it.
- A post that reaches the spam score is stored as spam. It gets the same success answer, but sends no email and no webhook.
- Every other post sends one email to each recipient, with the visitor's address as the reply-to.
- The email arrives in your inbox seconds later. Pressing Reply writes to the visitor.
Six ways to get a form into your inbox
Each of these works for someone. The table is about what each one costs you once real visitors, and real bots, start using the form.
| Approach | Good at | Trade-offs |
|---|---|---|
action="mailto:you@example.com" | No server, no account, one attribute. | Nothing is sent. The browser opens the visitor's mail app with a draft, or does nothing if no mail app is set up. Your address sits in the page for scrapers. See the Gmail guide. |
A PHP script with mail() | Runs on almost any shared PHP host, with no third party. | Depends on the host's mail setup, and mail that claims to come from your domain often fails SPF or DMARC and lands in spam. No spam filtering, no storage, and no PHP at all on static hosts. See without PHP. |
| A browser email SDK, such as EmailJS | No server. Email templates you design in their dashboard. | The keys and template ids are in your page's JavaScript, so anyone can send your templates with their own text. See the EmailJS alternative. |
| Your own route plus an email API, such as Resend, Postmark, Amazon SES or Nodemailer over SMTP | Full control: your subject, your HTML, a confirmation to the visitor. | You build validation, spam filtering, rate limits, storage and retries, verify a sending domain, and need a server even for a static site. See the Next.js guide. |
| Google Apps Script as a web app | Runs on a Google account you already have. | Script quotas cap how many recipients it can email a day, the URL has no spam protection, and you maintain the script. |
| A form backend, such as Ikiform, Formspree or Web3Forms | One URL in action. Spam filtering, storage and email with no server. | The email's layout and subject are the service's. With Ikiform, nothing is ever emailed to the visitor. |
If you need a fully branded email, or a confirmation to the visitor, write your own route with an email API. If you want the message in your inbox and a way to reply, with spam kept out, a form backend is the least code. The rest of this guide uses Ikiform.
The form
Three fields cover most contact forms: who they are, where to reply, and what they want. Pick your stack below; the choice carries over to every code sample in these guides.
This form works with no JavaScript at all: the browser posts it, Ikiform emails you, and the visitor lands on the page in _redirect. Each field has an empty error slot under it for the script below.
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<p data-error="name"></p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p data-error="email"></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
<p data-error="message"></p>
<input type="hidden" name="page" value="/contact" />
<input type="hidden" name="_redirect" value="https://example.com/contact/sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<p id="contact-status" role="status"></p>
</form>The script keeps the visitor on the page. It posts with fetch, writes each server error under its field, and swaps the form for a line that repeats the address you'll reply to, so a typo is easy to spot.
const form = document.getElementById("contact");
const status = document.getElementById("contact-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
for (const slot of form.querySelectorAll("[data-error]")) slot.textContent = "";
button.disabled = true;
status.textContent = "Sending";
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) {
const email = form.elements.namedItem("email").value;
const done = document.createElement("p");
done.setAttribute("role", "status");
done.textContent = `Sent. We'll reply to ${email}.`;
form.replaceWith(done);
return;
}
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
const slot = form.querySelector(`[data-error="${field}"]`);
if (slot) slot.textContent = messages[0];
}
status.textContent = result.error.fields ? "Check the fields above." : result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});A server action posts the message with your secret key. It maps Ikiform's error.fields to one message per field and returns the typed values, so nothing the visitor wrote is lost on an error.
"use server";
import { headers } from "next/headers";
export interface ContactState {
sent: boolean;
email?: string;
errors: Record<string, string>;
values?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const values = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
message: String(formData.get("message") ?? "").trim(),
};
const origin = (await headers()).get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(formData.get("_gotcha") ?? "") }),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, email: values.email, errors: {} };
const errors: Record<string, string> = {};
for (const [field, messages] of Object.entries(result?.error?.fields ?? {})) {
errors[field] = (messages as string[])[0] ?? "Check this field.";
}
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your message. Try again.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type ContactState, sendMessage } from "./actions";
const initial: ContactState = { sent: false, errors: {} };
export function ContactForm() {
const [state, action, pending] = useActionState(sendMessage, initial);
if (state.sent) return <p role="status">Sent. We'll reply to {state.email}.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={state.values?.name} />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} defaultValue={state.values?.message} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables. Each server sample passes on the browser's Origin header when there is one, because a server's own fetch sends none and Ikiform counts free localhost test submissions by it.
Going further with Next.js? Read A Next.js contact form that sends email.
Zod checks each field in the action before anything is sent, and isInputError gives you its messages per field. The page must render on demand, so keep prerender = false and add an adapter.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "Write at least a sentence."),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, page: "/contact" }),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { email: input.email };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Sent. We'll reply to {result.data.email}.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{fields.message && <p role="alert">{fields.message[0]}</p>}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables. Each server sample passes on the browser's Origin header when there is one, because a server's own fetch sends none and Ikiform counts free localhost test submissions by it.
The server function reads the FormData in .validator(), posts it from the server and returns either the reply address or one error per field.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Outcome = { sent: true; email: string } | { sent: false; errors: Record<string, string> };
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<Outcome> => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...data, page: "/contact" }),
});
const result = await response.json();
if (result.ok) return { sent: true, email: data.email };
const errors: Record<string, string> = { form: String(result.error.message) };
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
errors[field] = String((messages as string[])[0]);
}
return { sent: false, errors };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
const errors = outcome && !outcome.sent ? outcome.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ sent: false, errors: { form: "Couldn't send your message. Try again." } });
} finally {
setPending(false);
}
}
if (outcome?.sent) return <p role="status">Sent. We'll reply to {outcome.email}.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.name && !errors.email && !errors.message ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables. Each server sample passes on the browser's Origin header when there is one, because a server's own fetch sends none and Ikiform counts free localhost test submissions by it.
The route action forwards the message and passes Ikiform's field errors back through actionData, with Ikiform's status code kept by data().
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: String(formData.get("name") ?? "").trim(),
email,
message: String(formData.get("message") ?? "").trim(),
page: "/contact",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true as const, email };
const fields: Record<string, string[]> = result.error.fields ?? {};
return data(
{ sent: false as const, message: String(result.error.message), fields },
{ status: response.status },
);
}
export default function Contact({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) return <p role="status">Sent. We'll reply to {actionData.email}.</p>;
const fields = actionData?.fields ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</Form>
);
}The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables. Each server sample passes on the browser's Origin header when there is one, because a server's own fetch sends none and Ikiform counts free localhost test submissions by it.
The default action returns fail() with the field errors. update({ reset: false }) keeps what the visitor typed when the server answers with an error. Declare IKIFORM_SECRET_KEY in src/env.ts with defineEnvVars so $app/env/private can read it.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
};
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(data.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
return fail(response.status, { values, fields, message: String(result.error.message) });
}
return { sentTo: values.email };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const fields = $derived<Record<string, string[]>>(form?.fields ?? {});
</script>
{#if form?.sentTo}
<p role="status">Sent. We'll reply to {form.sentTo}.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{#if fields.name}<p role="alert">{fields.name[0]}</p>{/if}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{#if fields.email}<p role="alert">{fields.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{#if fields.message}<p role="alert">{fields.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message && !Object.keys(fields).length}<p role="alert">{form.message}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
{/if}The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables. Each server sample passes on the browser's Origin header when there is one, because a server's own fetch sends none and Ikiform counts free localhost test submissions by it.
A server route holds the secret key and passes Ikiform's message on as createError's message and the field errors in its data; $fetch hands both back on the client as data.message and data.data.fields. Nuxt fills runtimeConfig.ikiformSecretKey from NUXT_IKIFORM_SECRET_KEY.
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; email?: string; message?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
message: body.message?.trim() ?? "",
page: "/contact",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { sentTo: body.email };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const values = reactive({ name: "", email: "", message: "", _gotcha: "" });
const fields = ref<Record<string, string[]>>({});
const error = ref("");
const sentTo = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
fields.value = {};
error.value = "";
try {
const result = await $fetch("/api/contact", { method: "POST", body: values });
sentTo.value = result.sentTo ?? values.email;
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
fields.value = data?.data?.fields ?? {};
error.value = data?.message ?? "Couldn't send your message. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="values.name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" v-model="values.email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="values.message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input v-model="values._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables. Each server sample passes on the browser's Origin header when there is one, because a server's own fetch sends none and Ikiform counts free localhost test submissions by it.
With no server of your own, the browser posts straight to Ikiform. No key goes in this code; Allowed domains decides which sites may post.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [fields, setFields] = useState<Record<string, string[]>>({});
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setFields({});
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
setFields(result.error.fields ?? {});
setError(result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Sent. We'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error && !Object.keys(fields).length ? <p role="alert">{error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}The same browser-side post as a Vue component. The form keeps its action, so it still sends if the script fails to load.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sentTo = ref("");
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
sending.value = true;
fields.value = {};
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
sentTo.value = String(body.get("email"));
return;
}
fields.value = result.error.fields ?? {};
error.value = result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>- Replace
frm_xxxxxxxxxxxxxxxxwith your form's id from the dashboard. name="email"makes that field the reply-to address of the notification email. Keep the name, even if the label says "Your email".pageis an ordinary hidden field. It shows up in the email, so you know which page the message came from._gotchais the honeypot. People never see it; a bot that fills it in is stored as spam and emails nobody._redirectonly works for anhttps:URL on one of the form's Allowed domains. Leave it out and set Redirect after submit in the form's settings instead, or Ikiform shows its own thank-you page.
The framework guides go further: Next.js, Astro, SvelteKit, Nuxt, React, Vue and plain HTML. Site builders such as Webflow and Carrd have their own pages, and the contact form template adds a topic field.
Define the fields in the dashboard too, and Ikiform refuses a post that doesn't match with 422 validation_failed and a message per field, which the code above shows next to each input. Try it here; this runs the same validator in your browser:
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
What the email looks like
Every accepted submission sends a plain, readable email. Here's the text version of one:
From: Ikiform <notifications@ikiform.com>
To: you@yourcompany.com
Reply-To: ada@example.com
Subject: New submission: Contact
Contact got a new submission on Oct 25, 2026, 9:14 AM UTC.
name: Ada Lovelace
email: ada@example.com
message: Do you ship to Canada? I'd like to order 40 units before December.
page: /contact
View it in Ikiform:
https://ikiform.com/dashboard/submissions?form=frm_xxxxxxxxxxxxxxxx&submission=sub_...- It comes from
notifications@ikiform.com. You don't set up DNS records or a sending domain, because the mail is sent from Ikiform's domain. - The subject is always
New submission: <form name>._subjectdoesn't change the subject of notification emails. It is passed on to webhooks and the API asmeta.subject. To tell forms apart, give each form a clear name in the dashboard, such as "Contact, pricing page". - Fields appear in the order they were posted, labelled by their
name. Fields starting with_are left out. - A very long form or answer is shortened, and the email says how many more fields are in the dashboard. The limits page has the numbers.
- Fields that look like passwords or card numbers are left out, and the email for a post that contains them is held for review. Don't collect credentials with a form backend.
- Workspace members get a View submission button. People outside the workspace get the fields and an unsubscribe link.
Who gets the email
Recipients are set per form in the dashboard, never by the form itself. A field that named a recipient would let anyone use your form to email anyone, so Ikiform doesn't have one.
| Who | How they're added | Notes |
|---|---|---|
| The default | Nothing to do | The member chosen as the workspace's Notification email, or every workspace owner when none is chosen |
| Chosen members | Tick them under the form's Notifications settings | Only members with a verified email address |
| People outside the workspace | Add someone outside the workspace, then Send link | They get email only after they confirm. Up to 5 per form, and each can unsubscribe |
Each form emails at most 10 addresses. Every recipient gets their own copy rather than a shared CC, so nobody sees the others' addresses. If a recipient's mailbox bounces, Ikiform pauses email to that address for a while and the dashboard shows Emails bounced next to it. Multiple recipients covers routing by topic, which recipients alone can't do.
Reply to the person, not to Ikiform
Ikiform sets the reply-to address from a _replyto field first, then from a field named email, email_address, your_email, reply_to, contact_email. The value must be one valid email address, or it is ignored. With a reply-to set, pressing Reply in Gmail, Outlook or Apple Mail opens a reply to the visitor, and your answer comes from your own address.
Without one, a reply goes to notifications@ikiform.com, which nobody reads. That's the most common surprise, and it's usually a field called user_email or Email Address with a space. Rename it, or add _replyto. The reply-to guide covers forms with two email fields and builders that rename fields for you.
When a lot arrives at once
Each form sends up to 50 notification emails an hour, or 10 while the workspace is younger than 7 days. Past that, the rest of the hour's submissions are stored as usual and arrive as one digest email after the hour ends. The cap protects your inbox, and your mail provider's opinion of Ikiform's mail, when a form is posted somewhere big or a bot gets past the filters.
- The first 50 submissions to a form in a clock hour each send an email as usual.
- Later submissions in the same hour are stored and shown in the dashboard straight away, without an email each.
- After the hour ends, each recipient gets one digest that counts them and links to the dashboard.
The digest's subject is the count and the form name, and it doesn't list the fields or set a reply-to, so answer those messages from the dashboard. If you regularly go past the cap, email isn't the right channel for that form: send it to Slack or a shared inbox through a webhook instead.
Keep spam out of your inbox
Email makes spam expensive: every bot post is a notification someone opens. Ikiform stores spam with a normal success answer, so the bot learns nothing, and sends no email for it. Four things decide what counts as spam:
| Defence | What it catches | Setup |
|---|---|---|
| Honeypot | Bots that fill in every input | The hidden _gotcha input |
| Spam scoring | Links in the name field, three or more links, known spam phrases, disposable addresses and identical repeats. At 5 the post is spam | None |
| Allowed domains | Posts from other sites | Add your domain in the form's settings |
| Human check | Bots that run a real browser | Set Spam check to Ikiform |
Check the Spam filter in the dashboard now and then. If a real message lands there, mark it as not spam and its email goes out then. The spam docs explain each signal, including when to turn on the human check.
What email can't do
Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a webhook. If your site promises "we've received your message", show that on the page after submit, where the code above already does.
- A shared team inbox. Add the team address as a recipient, or forward from your mail provider.
- A chat channel. Send each submission to Slack, Discord or Microsoft Teams by webhook. Chat suits forms that get more than a few messages a day.
- A spreadsheet or CRM. Google Sheets, or any tool through Zapier or n8n.
- Attachments. File uploads aren't supported yet. A post with a file gets
422 files_not_supported. Ask for a link to a shared file instead.
Turning email off
Some forms are better without email, such as a busy newsletter signup. Switch off Email new submissions in the form's Notifications settings. Submissions are still stored, and webhooks still fire.
Pick your situation
The setup above is the same everywhere. These pages cover a particular inbox, a particular stack, or the tool you're moving away from.
- Without PHPReplace a contact.php script that calls mail() with a form that posts to a hosted endpoint, keeping your field names, your thank-you page and replies.
- To GmailGet form submissions in Gmail without mailto: links, app passwords or Apps Script, then label them, keep them out of spam and reply from Gmail.
- Multiple recipientsEmail one form to your whole team, or route each message to the right person by topic, without letting the form itself pick who gets the mail.
- Reply from your inboxMake Reply on a form notification write to the person who filled in the form: which field names work, when to use _replyto, and why replies go astray.
- EmailJS alternativeMove a contact form off EmailJS: why browser-side email keys are a risk, what changes in your code, and what you give up, such as the auto-reply.
- Next.jsTwo ways to email a Next.js form with a Server Action: send it yourself with Resend or Nodemailer, or forward it to a form backend, compared honestly.
Check it works
Run through this before you put the form live. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Under the form's Notifications settings, check that Email new submissions is on and the right people are ticked.
- Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- Open the notification email. The subject is
New submission:and the form's name, and every field you sent is listed. - Press Reply. The To line shows the address you typed in the form, not Ikiform's.
- 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.
Troubleshooting
| Problem | What to do |
|---|---|
| The submission is in the dashboard but no email arrived | Look in your spam or promotions folder first, and add notifications@ikiform.com to your contacts. Then check the form's Notifications settings: email may be off, an outside address may still say Waiting for confirmation, or it may show Emails bounced. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks. |
| Reply goes to notifications@ikiform.com | No reply-to was found. Name the email field one of email, email_address, your_email, reply_to, contact_email, or add a _replyto field with the visitor's address. |
| Emails stopped and a digest arrived instead | The form went past 50 emails in an hour (10 on a new workspace). Everything is in the dashboard. Look for a bot burst under Spam, or use a webhook for busy forms. |
_subject doesn't change the subject | _subject doesn't change the subject of notification emails. It is passed on to webhooks and the API as meta.subject. |
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 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
402 test_limit_reached | The workspace has no plan and used its 50 free localhost tests this month. Pick a plan, or use Send test in the dashboard. |
Questions
Not by itself. A form only makes an HTTP request, and sending mail needs a server that talks to a mail server. A form backend is that server: point the form's action at it and it emails you, so your own site can stay static.
A mailto: action doesn't send anything. It asks the visitor's computer to open a mail app with a draft, which fails for anyone without one set up, and the visitor still has to press Send. Many people give up there, and you never know they tried.
No. The subject is always "New submission:" followed by the form's name, so name each form clearly. A _subject field is stored and passed to webhooks and the API, but doesn't change the email.
Up to 10 per form: workspace members you tick in the form's settings, plus up to 5 outside addresses that confirm by link. Each gets their own copy.
No. Ikiform emails you and your team, not the submitter, so there is no autoresponder or confirmation email. Send a webhook to your email tool and send the confirmation from there.
They're sent from Ikiform's own domain, so you don't need SPF or DKIM records for your domain. The first one can still land in spam or promotions; add notifications@ikiform.com to your contacts or create a filter so it never does.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.