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

Published 2026-10-25 · https://ikiform.com/guides/send-form-to-email

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:

Diagram: How a form post becomes an email in your inbox

1. The visitor submits your form, and the browser or your server posts it to your Ikiform endpoint.
2. Ikiform checks the fields against the form's definition, scores the post for spam, and stores it.
3. A post that reaches the spam score is stored as spam. It gets the same success answer, but sends no email and no webhook.
4. Every other post sends one email to each recipient, with the visitor's address as the reply-to.
5. The email arrives in your inbox seconds later. Pressing Reply writes to the visitor.

The visitor never sees an email address and never opens a mail app. The only thing on your page is a form.

## 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](https://ikiform.com/guides/send-form-to-email/gmail.md). |
| 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](https://ikiform.com/guides/send-form-to-email/without-php.md). |
| 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](https://ikiform.com/guides/send-form-to-email/emailjs-alternative.md). |
| 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](https://ikiform.com/guides/send-form-to-email/nextjs.md). |
| 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](https://ikiform.com/alternatives/formspree) or [Web3Forms](https://ikiform.com/alternatives/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.

**HTML**

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.

`contact.html`:

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

`contact.js`:

```js
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;
});
```

**Next.js**

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.

`app/contact/actions.ts`:

```ts
"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 };
}
```

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

```tsx
"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](https://ikiform.com/docs/rate-limits.md) 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](https://ikiform.com/guides/send-form-to-email/nextjs.md).

**Astro**

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.

`src/actions/index.ts`:

```ts
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 };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
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](https://ikiform.com/docs/rate-limits.md) 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.

**TanStack Start**

The server function reads the `FormData` in `.validator()`, posts it from the server and returns either the reply address or one error per field.

`src/routes/contact.tsx`:

```tsx
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](https://ikiform.com/docs/rate-limits.md) 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.

**React Router**

The route `action` forwards the message and passes Ikiform's field errors back through `actionData`, with Ikiform's status code kept by `data()`.

`app/routes/contact.tsx`:

```tsx
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](https://ikiform.com/docs/rate-limits.md) 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.

**SvelteKit**

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.

`src/routes/contact/+page.server.ts`:

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

`src/routes/contact/+page.svelte`:

```svelte
<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](https://ikiform.com/docs/rate-limits.md) 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.

**Nuxt**

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

`server/api/contact.post.ts`:

```ts
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 };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`app/components/ContactForm.vue`:

```vue
<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](https://ikiform.com/docs/rate-limits.md) 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.

**React**

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.

`src/ContactForm.tsx`:

```tsx
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>
  );
}
```

**Vue**

The same browser-side post as a Vue component. The form keeps its `action`, so it still sends if the script fails to load.

`src/components/ContactForm.vue`:

```vue
<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_xxxxxxxxxxxxxxxx` with 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".
- `page` is an ordinary hidden field. It shows up in the email, so you know which page the message came from.
- `_gotcha` is the honeypot. People never see it; a bot that fills it in is stored as spam and emails nobody.
- `_redirect` only works for an `https:` 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](https://ikiform.com/docs/guides/nextjs.md), [Astro](https://ikiform.com/docs/guides/astro.md), [SvelteKit](https://ikiform.com/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/docs/guides/nuxt.md), [React](https://ikiform.com/docs/guides/react.md), [Vue](https://ikiform.com/docs/guides/vue.md) and [plain HTML](https://ikiform.com/docs/html.md). Site builders such as [Webflow](https://ikiform.com/docs/guides/webflow.md) and [Carrd](https://ikiform.com/docs/guides/carrd.md) have their own pages, and the [contact form template](https://ikiform.com/forms/contact-form) 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:

Form: Contact

- **Name** (`name`, text, required)
- **Your email** (`email`, email, required)
- **Message** (`message`, textarea, required)

Paste this definition on the form's **Fields** tab. Without a definition, Ikiform accepts any field names and emails whatever arrives.

## What the email looks like

Every accepted submission sends a plain, readable email. Here's the text version of one:

`Notification email`:

```text
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>`. `_subject` doesn't change the subject of notification emails. It is passed on to webhooks and the API as `meta.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](https://ikiform.com/docs/limits.md#notifications) 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](https://ikiform.com/guides/send-form-to-email/multiple-recipients.md) 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](https://ikiform.com/guides/send-form-to-email/reply-to.md) 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.

Diagram: What happens to notification emails in a busy hour

1. The first 50 submissions to a form in a clock hour each send an email as usual.
2. Later submissions in the same hour are stored and shown in the dashboard straight away, without an email each.
3. After the hour ends, each recipient gets one digest that counts them and links to the dashboard.

The cap counts per form, so a burst on one form doesn't slow another. Webhooks aren't capped and still fire for every submission.

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](https://ikiform.com/blog/form-submissions-to-slack.md) or a shared inbox through a [webhook](https://ikiform.com/docs/webhooks.md) 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](https://ikiform.com/docs/spam.md) 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](https://ikiform.com/docs/webhooks.md). 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](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md) or [Microsoft Teams](https://ikiform.com/blog/form-submissions-to-microsoft-teams.md) by webhook. Chat suits forms that get more than a few messages a day.
- **A spreadsheet or CRM.** [Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md), or any tool through [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md).
- **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.

> The cap starts lower on new workspaces: 10 emails per form an hour for the first 7 days. If you're testing with a script, expect a digest sooner than you would later.

## 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 PHP](https://ikiform.com/guides/send-form-to-email/without-php.md): Replace 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 Gmail](https://ikiform.com/guides/send-form-to-email/gmail.md): Get 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 recipients](https://ikiform.com/guides/send-form-to-email/multiple-recipients.md): Email 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 inbox](https://ikiform.com/guides/send-form-to-email/reply-to.md): Make 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 alternative](https://ikiform.com/guides/send-form-to-email/emailjs-alternative.md): Move 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.js](https://ikiform.com/guides/send-form-to-email/nextjs.md): Two 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](https://ikiform.com/pricing).

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. Under the form's **Notifications** settings, check that **Email new submissions** is on and the right people are ticked.
3. Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
4. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
5. Open the notification email. The subject is `New submission:` and the form's name, and every field you sent is listed.
6. Press **Reply**. The **To** line shows the address you typed in the form, not Ikiform's.
7. 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.
8. 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

### Can an HTML form send email without a server?

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.

### Why not use mailto: in the form action?

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.

### Can I change the subject line of the notification email?

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.

### How many people can get 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.

### Can Ikiform email the person who submitted the form?

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.

### Will form emails land in spam?

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.

### Is Ikiform free?

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.

## More guides

- [Without PHP](https://ikiform.com/guides/send-form-to-email/without-php.md): Replace 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 Gmail](https://ikiform.com/guides/send-form-to-email/gmail.md): Get 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 recipients](https://ikiform.com/guides/send-form-to-email/multiple-recipients.md): Email 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 inbox](https://ikiform.com/guides/send-form-to-email/reply-to.md): Make 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 alternative](https://ikiform.com/guides/send-form-to-email/emailjs-alternative.md): Move 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.js](https://ikiform.com/guides/send-form-to-email/nextjs.md): Two 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.
- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
- [Outgrow builder forms](https://ikiform.com/guides/website-builder-forms.md): When Webflow, Netlify, Carrd or Framer forms hit a cap or a missing feature, keep the form you designed and point it at a form backend instead.
- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
