Guides15 min read
Build a lead capture form that reaches you fast
A lead capture form on your own site that emails you with reply-to, pings team chat and feeds your CRM, so you answer while the lead is still there.
Put a short form on your own page that posts name, work email and one qualifying question to a form backend. Turn on notification emails so each lead lands in your inbox with Reply pointed at the lead, and send a signed webhook to your team chat and your CRM. Then measure the minutes from submit to first reply, because that number decides more deals than the form design does.
A lead form is a contact form with a job: start a sales conversation. People who fill it in are comparing you with someone else, often in another tab. The team that answers first, with a useful reply from a real person, usually gets the call. This guide builds the form on your own site, gets each lead to you within seconds, and leaves the CRM optional.
How a lead travels
The form posts to Ikiform. Ikiform checks the post for spam, stores it, and then does three things at once: emails your team with the lead as the reply-to address, sends a signed webhook, and shows the lead in the dashboard. The webhook is how the lead reaches team chat and your CRM.
- A visitor fills in the lead form on your site, and it posts to your Ikiform endpoint.
- Ikiform checks the honeypot, the spam score and the rate limit, then stores the lead.
- Seconds later each notification recipient gets an email, and pressing Reply writes to the lead.
- The lead shows up in the dashboard, where you can search, filter and export it.
- Ikiform sends a signed submission.created webhook.
- A relay or an automation tool posts the lead to team chat and creates the contact in your CRM.
Four ways to capture leads
Each common setup is good at something. Pick by where you want leads to live and who maintains the form.
| Approach | Good at | Trade-offs |
|---|---|---|
| Your CRM's own form, such as HubSpot forms or Pipedrive's web forms | Leads land in the CRM as contacts with no glue. Tracking and lead scoring live in one product. | The form is embedded with the CRM's script and styling. Moving CRMs means rebuilding every form. Some features depend on the plan. |
| Form builder embed, such as Typeform or Tally | Polished multi-step forms with logic and no code. | An embed that looks like the builder unless you pay to restyle it. Webhooks and response limits depend on the plan. |
| Your own API route and database | Any logic you want, such as lead scoring before you store anything. | You build spam filtering, rate limits, email alerts, retries and export, and keep a server running for one form. |
| Form backend, such as Ikiform | Your own HTML on any site. Spam filtering, email alerts with reply-to, signed webhooks and CSV export with no server. | No lead scoring, no CRM records and no email to the lead. Those come from your CRM or email tool, by webhook. |
If your whole sales process runs inside one CRM and you're happy with its form, use it. A form backend fits when the form must match your site, when you might change CRM, or when you want the same form to reach email, chat and a CRM without picking one.
Ask less, qualify once
Every field you add costs submissions. Most lead forms need four things: who to reply to, where they work, what they want, and room to explain. One fixed-choice question does the qualifying, because it arrives as one of a few known values your routing can trust. Try it below; it runs Ikiform's validator in your browser and shows what would be stored, or the 422 you'd get back.
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
| Field | Why | Keep in mind |
|---|---|---|
name | A reply that starts with their name | Keep the id name. A link in a name field is a strong spam signal |
email | Who Reply writes to | Keep the id email. Labelling it Work email nudges people to use one, but doesn't enforce it |
company | Look them up before you answer | Optional. Requiring it costs submissions from freelancers |
interest | Routing and priority | A select stores one listed value or refuses the post, so automations can branch on it |
message | Context for the first reply | Optional. A required essay box is the field people abandon on |
Phone numbers, team size and budget belong on forms where they change what you do next. The quote request template is the fuller version, with service, budget and timeline as fixed choices. If a sales rep calls every lead, ask for a phone. If you route by team size, ask for it as a select. Otherwise leave them for the first reply.
The form
Pick your stack. Every version shows the server's message under the field it belongs to and keeps what the visitor typed, because losing a half-written message to one typo is how leads give up. The success state names who replies and when, so nobody wonders if it worked.
The form posts the plain way and redirects to your thank-you page, so it works before any script loads. Each field has an empty slot for its error.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="lead-form" novalidate>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p class="error" data-error-for="name" hidden></p>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p class="error" data-error-for="email" hidden></p>
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" required>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<p class="error" data-error-for="message" hidden></p>
<input type="hidden" name="page" value="pricing" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Talk to sales</button>
</form>The script keeps the visitor on the page. It shows each field's error from the server under that field, and swaps the form for a line that says who replies and when.
const form = document.getElementById("lead-form");
function showErrors(fields) {
form.querySelectorAll("[data-error-for]").forEach((slot) => {
const message = fields[slot.dataset.errorFor]?.[0];
slot.textContent = message ?? "";
slot.hidden = !message;
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
button.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 thanks = document.createElement("p");
thanks.setAttribute("role", "status");
thanks.textContent = `Thanks, ${form.elements.name.value.trim().split(" ")[0]}. A person on our team will reply to ${form.elements.email.value} within one working day.`;
form.replaceWith(thanks);
return;
}
showErrors(result.error.fields ?? {});
if (!result.error.fields) alert(result.error.message);
} catch {
alert("Something went wrong. Email sales@example.com and we'll pick it up.");
}
button.disabled = false;
button.textContent = "Talk to sales";
});The server action forwards the lead with your secret key and returns the first error for each field, plus the values the visitor typed, so nothing is lost on a 422.
"use server";
import { headers } from "next/headers";
export interface LeadState {
sent: boolean;
firstName?: string;
errors: Record<string, string>;
values: Record<string, string>;
}
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"] as const;
export async function sendLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").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),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, firstName: values.name.split(" ")[0], errors: {}, values: {} };
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages[0] ?? ""]));
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type LeadState, sendLead } from "./actions";
const initial: LeadState = { sent: false, errors: {}, values: {} };
export function LeadForm() {
const [state, action, pending] = useActionState(sendLead, initial);
const { errors, values } = state;
if (state.sent) {
return <p role="status">Thanks, {state.firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" defaultValue={values.company} />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest" defaultValue={values.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}Going further with Next.js? Read Build a qualifying lead form in Next.js.
Zod checks the fields before anything leaves your server, and isInputError gives you its messages per field. The page must render on demand for the form to post to the action.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendLead: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us who to reply to."),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().optional(),
interest: z.enum(["pricing", "demo", "custom", "other"]),
message: z.string().trim().max(5000).optional(),
page: z.string().default("pricing"),
_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),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { firstName: input.name.split(" ")[0] };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendLead);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Thanks, {result.data.firstName}. A person on our team will reply within one working day.</p>
) : (
<form method="POST" action={actions.sendLead}>
<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">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Talk to sales</button>
</form>
)}Going further with Astro? Read Build a qualifying lead form in Astro.
The server function reads the FormData in .validator(), forwards it, and returns either the first name or a map of field errors that the route renders under each input.
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 Result = { ok: true; firstName: string } | { ok: false; errors: Record<string, string> };
const sendLead = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const read = (key: string) => String(data.get(key) ?? "").trim();
return {
name: read("name"),
email: read("email"),
company: read("company"),
interest: read("interest"),
message: read("message"),
page: read("page"),
_gotcha: read("_gotcha"),
};
})
.handler(async ({ data }): Promise<Result> => {
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),
});
const result = await response.json();
if (result.ok) return { ok: true, firstName: data.name.split(" ")[0] ?? "" };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return { ok: false, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""])) };
});
export const Route = createFileRoute("/contact-sales")({ component: ContactSales });
function ContactSales() {
const send = useServerFn(sendLead);
const [result, setResult] = useState<Result | null>(null);
const [pending, setPending] = useState(false);
const errors = result && !result.ok ? result.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setResult(await send({ data: new FormData(event.currentTarget) }));
} catch {
setResult({ ok: false, errors: { form: "Something went wrong. Email sales@example.com and we'll pick it up." } });
} finally {
setPending(false);
}
}
if (result?.ok) {
return <p role="status">Thanks, {result.firstName}. A person on our team will reply within one working day.</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">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}Going further with TanStack Start? Read Build a qualifying lead form in TanStack Start.
The route action forwards the post and returns field errors with Ikiform's status code through data(). The component reads them from actionData.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact-sales";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const values = Object.fromEntries(KEYS.map((key) => [key, String(formData.get(key) ?? "").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(values),
});
const result = await response.json();
if (result.ok) return { sent: true as const, firstName: values.name.split(" ")[0] };
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
return data(
{ sent: false as const, errors: Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]])) },
{ status: response.status },
);
}
export default function ContactSales({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) {
return <p role="status">Thanks, {actionData.firstName}. A person on our team will reply within one working day.</p>;
}
const errors: Record<string, string | undefined> = actionData?.errors ?? {};
return (
<Form method="post">
<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">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</Form>
);
}Going further with React Router? Read Build a qualifying lead form in React Router.
The form action returns fail() with the values and the errors, and update({ reset: false }) keeps what the visitor typed on screen while they fix one field. 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";
const KEYS = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = Object.fromEntries(KEYS.map((key) => [key, String(data.get(key) ?? "").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),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? { form: [result.error.message] };
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0]]));
return fail(response.status, { values, errors });
}
return { firstName: values.name.split(" ")[0] };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
</script>
{#if form?.firstName}
<p role="status">Thanks, {form.firstName}. A person on our team will reply within one working day.</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 value={form?.values?.name ?? ""} />
{#if errors.name}<p role="alert">{errors.name}</p>{/if}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
{#if errors.email}<p role="alert">{errors.email}</p>{/if}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" value={form?.values?.company ?? ""} />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest" value={form?.values?.interest ?? "pricing"}>
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4">{form?.values?.message ?? ""}</textarea>
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if errors.form}<p role="alert">{errors.form}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
{/if}Going further with SvelteKit? Read Build a qualifying lead form in SvelteKit.
A server route keeps the key off the client and passes Ikiform's field errors back in the error's data. Set NUXT_IKIFORM_SECRET_KEY in the environment.
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string | undefined>>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const keys = ["name", "email", "company", "interest", "message", "page", "_gotcha"];
const values = Object.fromEntries(keys.map((key) => [key, String(body[key] ?? "").trim()]));
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(values),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: result.error.message,
data: { fields: result.error.fields ?? {} },
});
}
return { firstName: values.name?.split(" ")[0] };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const lead = reactive({ name: "", email: "", company: "", interest: "pricing", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const failure = ref("");
const sending = ref(false);
const firstName = ref("");
async function send() {
sending.value = true;
errors.value = {};
failure.value = "";
try {
const result = await $fetch("/api/lead", { method: "POST", body: { ...lead, page: "pricing" } });
firstName.value = result.firstName ?? "there";
} catch (error) {
const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
errors.value = data?.data?.fields ?? {};
if (!Object.keys(errors.value).length) failure.value = data?.message ?? "Something went wrong. Email sales@example.com and we'll pick it up.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="firstName" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="lead.name" name="name" autocomplete="name" required />
<p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Work email</label>
<input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="company">Company</label>
<input id="company" v-model="lead.company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" v-model="lead.interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" v-model="lead.message" name="message" rows="4" />
<input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="failure" role="alert">{{ failure }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
</form>
</template>Going further with Nuxt? Read Build a qualifying lead form in Nuxt.
With no server of your own, the form posts straight to Ikiform from the browser. Keep secret keys out of this code; Allowed domains protects the form instead.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function LeadForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [firstName, setFirstName] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setErrors({});
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setFirstName(String(body.get("name")).trim().split(" ")[0] ?? "");
setStatus("sent");
return;
}
setErrors(result.error.fields ?? { form: [result.error.message] });
} catch {
setErrors({ form: ["Something went wrong. Email sales@example.com and we'll pick it up."] });
}
setStatus("idle");
}
if (status === "sent") {
return <p role="status">Thanks, {firstName}. A person on our team will reply within one working day.</p>;
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name[0]}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email[0]}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form ? <p role="alert">{errors.form[0]}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Talk to sales"}</button>
</form>
);
}The same browser-side post as a Vue component. The form keeps its action, so a visitor whose script fails to load can still reach you.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "sent">("idle");
const errors = ref<Record<string, string[]>>({});
const firstName = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
errors.value = {};
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
firstName.value = String(body.get("name")).trim().split(" ")[0] ?? "";
status.value = "sent";
return;
}
errors.value = result.error.fields ?? { form: [result.error.message] };
} catch {
errors.value = { form: ["Something went wrong. Email sales@example.com and we'll pick it up."] };
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks, {{ firstName }}. A person on our team will reply within one working day.</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="errors.name" role="alert">{{ errors.name[0] }}</p>
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" />
<label for="interest">What can we help with?</label>
<select id="interest" name="interest">
<option value="pricing">Pricing for my team</option>
<option value="demo">A live demo</option>
<option value="custom">A custom plan or contract</option>
<option value="other">Something else</option>
</select>
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4" />
<input type="hidden" name="page" value="pricing" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="errors.form" role="alert">{{ errors.form[0] }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Talk to sales" }}</button>
</form>
</template>- Replace
frm_xxxxxxxxxxxxxxxxwith your form's id from the dashboard. - The hidden
_gotchainput is the honeypot. People never see it; bots that fill in every input mark themselves as spam. - The hidden
pageinput records which page the lead came from: pricing, a feature page, a campaign landing page. If you define fields, addpageas an optional text field, or the post is refused withUnknown field. - The server versions forward the visitor's
Originheader, because a server-side fetch sends none and Ikiform detects freelocalhosttests from it. - The plain HTML form needs Redirect after submit, or a
_redirectinput on one of your Allowed domains, to land on your own thank-you page.
IKIFORM_SECRET_KEY in server-only environment variables.The docs cover each framework's setup in depth: Next.js, Astro, SvelteKit, Nuxt, React, Vue, posting from a server and plain HTML.
Reach the lead in minutes
The form is the easy part. What separates a lead form that sells from one that collects is the time between submit and a reply from a person. Here is what that path looks like when it works:
- The lead submits the form.
- Ikiform stores it and answers the browser in well under a second.
- The notification email and the webhook go out from a queue seconds later.
- Someone on the team presses Reply in their inbox, or the mailto link in chat, and the reply goes to the lead.
Reply from your inbox
Each notification email sets its reply-to address to the lead. Ikiform takes it from a _replyto field first, then from a field named email, email_address, your_email, reply_to, contact_email. Keep the field id email and Reply goes to the lead, not to Ikiform. The subject is New submission: plus the form's name, so name the form after what it is, such as Talk to sales, and filter on that subject to label or star leads.
Add up to 10 recipients per form in the form's notification settings: workspace members, or outside addresses that confirm by email first. Send it to the people who reply, not to a shared alias nobody owns: a lead in five inboxes and claimed by no one waits longer than a lead in one.
Ping the team where they are
Email works when someone owns the inbox. A team that lives in chat answers faster when the lead shows up there. Add a webhook to Slack, Teams or Discord following Slack, Microsoft Teams or Discord. Put a mailto: link in the message so the person who claims it can reply in one click.
Route by what they asked for
Because interest only accepts listed values, a small relay can branch on it with confidence. This route sends demo requests and contract questions to their own channels and everything else to the main sales channel:
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
const CHANNELS: Record<string, string | undefined> = {
demo: process.env.SLACK_DEMO_WEBHOOK,
custom: process.env.SLACK_ENTERPRISE_WEBHOOK,
};
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, headers: request.headers, secret: process.env.IKIFORM_WEBHOOK_SECRET! });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const lead = event.data.fields;
const interest = String(lead.interest ?? "other");
const target = CHANNELS[interest] ?? process.env.SLACK_SALES_WEBHOOK!;
const text = [
`*${lead.name}* from ${lead.company || "an unknown company"} wants: ${interest}`,
`Reply: mailto:${lead.email}`,
lead.message ? `> ${String(lead.message).slice(0, 300)}` : "",
]
.filter(Boolean)
.join("\n");
const response = await fetch(target, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
}verifyWebhookchecks the signature and timestamp, so nobody else can post fake leads into your channels.- A
502when Slack is down makes Ikiform retry the same delivery, with the samewebhook-id, for about 3 days. - Change the label of an option freely. Keep its value stable once a relay or automation depends on it.
Keep junk out of the pipeline
Lead forms attract a particular kind of spam: people selling SEO, link building and outsourced development to you. It wastes the fastest reply you have, so filter it before it reaches anyone.
| Defence | What it stops | Setup |
|---|---|---|
| Honeypot | Bots that fill in every input | The hidden _gotcha input |
| Spam scoring | Known sales-pitch phrases such as seo services (+2), links in the name (+3), three or more links (+2), repeats (+3). At 5 it is spam | None |
| Rate limit | Scripts that post in a loop | Set the form to Strict, 2 every 10 minutes per visitor. A real lead rarely submits twice in ten minutes |
| Allowed domains | Posts from other sites | Add your domain under Allowed domains |
| Human check | Bots in a real browser | Set Spam check to Ikiform. The fetch versions then need a token from challenge() |
Spam gets the same success answer as a real lead, sends no email or webhook, and doesn't count toward usage. That also means a misjudged lead is silent. Check the Spam filter once a week; marking one as not spam sends its email and webhooks then. The spam docs list every signal.
Know where leads come from
Each submission already records the page's origin and referrer, which shows up in the webhook as meta.origin and meta.referrer. Campaign tags need a little help, because the visitor often lands on one page and submits on another. This snippet copies utm_ parameters into hidden inputs and remembers them for the session:
<input type="hidden" name="utm_source" />
<input type="hidden" name="utm_medium" />
<input type="hidden" name="utm_campaign" />
<script>
const params = new URLSearchParams(location.search);
for (const key of ["utm_source", "utm_medium", "utm_campaign"]) {
let value = params.get(key);
try {
if (value) sessionStorage.setItem(key, value);
else value = sessionStorage.getItem(key);
} catch {}
const input = document.querySelector(`#lead-form [name="${key}"]`);
if (input && value) input.value = value.slice(0, 200);
}
</script>In a framework, read the same parameters from the URL on the server and render them as hidden inputs. With fields defined, add each utm_ id as an optional text field. Names that start with _ are never stored, so don't use them for data you want to keep.
What the lead sees next
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. For a lead form that is often fine: a quick personal reply beats an automated one. If you want an instant acknowledgement anyway, send it from your CRM or email tool when the webhook creates the contact, so replies and unsubscribes are tracked in one place.
Whatever you send, the on-page success message is the promise. Say who replies and by when, and keep it. A form that says one working day and answers in ten minutes earns trust; one that says ten minutes and answers tomorrow loses it.
Leads in your CRM
There are no one-click integrations. Every tool is reached through a signed webhook, directly or through an automation tool such as Zapier or n8n. For a CRM, that means a relay maps Ikiform's fixed webhook body to the CRM's create-contact call. Leads to HubSpot or Pipedrive walks through both, including duplicates and retries. Until you need a CRM, the dashboard and a weekly CSV export are a fine pipeline.
Pick your situation
The setup above works for any lead. These pages cover what changes for a particular kind of lead, and for getting leads into a CRM.
- Demo request formA book-a-demo form that stores the lead, then sends the visitor to Cal.com or Calendly with their name and email already filled in.
- Quote request formA request-a-quote form that collects briefs as shared links instead of uploads, puts a due date on every request, and keeps link-heavy leads out of spam.
- Consultation request formA consultation request form for accountants, lawyers, coaches and clinics that asks how and when to call, and leaves sensitive details for the call.
- Project inquiry formA start-a-project form for studios and freelancers that sorts inquiries into hot, warm and no-fit by budget and timing before anyone reads the brief.
- Leads to HubSpot or PipedriveMap each signed Ikiform webhook to HubSpot's contact upsert or Pipedrive's person and lead calls, without duplicate contacts when a delivery is retried.
- Next.js lead formA Next.js App Router lead form that validates with Zod in a server action, keeps first-touch UTMs in a cookie and sends qualified leads to your calendar.
- Astro lead formAn Astro lead form built on Actions: Zod validation, first-touch UTMs on a static site, an on-demand contact page and a redirect to your scheduler.
- TanStack Start lead formA TanStack Start lead form: global request middleware for first-touch UTMs, a server function with Zod, and a thrown redirect to your scheduler.
- React Router lead formA React Router framework-mode lead form: root middleware with a signed first-touch cookie, a route action that validates, and a redirect to your scheduler.
- SvelteKit lead formA SvelteKit lead form: the handle hook records first-touch UTMs, a form action validates with Valibot, and redirect() sends qualified leads to a calendar.
- Nuxt lead formA Nuxt lead form: server middleware records first-touch UTMs, one Zod-validated server route serves fetch and plain posts, and good leads book a call.
Check it works
Run through this before you send traffic to the page. 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. - 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.
- Check each recipient's inbox. The subject is
New submission:plus the form's name, and Reply opens a message to the address you entered. - If you added a webhook, open the endpoint in Webhooks. The delivery shows
succeededwith your server's answer. - 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.
- Time it: submit from your phone and note how long until the email and the chat message arrive.
Troubleshooting
| Problem | What to do |
|---|---|
| Reply goes to Ikiform's address, not the lead | The form has no field Ikiform recognises as the lead's email. Rename the field to email, or send a _replyto field with the address. Accepted names: email, email_address, your_email, reply_to, contact_email. |
422 with Unknown field for page or utm_source | The form has a field definition and the hidden input isn't in it. Add it as an optional text field, or remove the input. |
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. |
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. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
403 challenge_required | The form's spam check is set to Ikiform and a fetch post had no token. Get one with challenge(formId) from @ikiform/sdk before each submit, or turn the check off. |
| 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. |
| A recipient gets no notification emails | An address outside the workspace must confirm first, from the email Ikiform sends it. Check that it was confirmed, then check the recipient's spam folder. |
Questions
A form on your website that collects a potential customer's contact details and what they want, so your team can follow up. It differs from a general contact form in that it qualifies the lead, usually with one fixed-choice question, and it is wired to reach sales quickly.
Point the form at your Ikiform endpoint and keep notification emails on for the form. Each lead arrives within seconds, to up to 10 recipients, and pressing Reply writes to the lead as long as the email field is named email or the form sends _replyto.
As few as let you reply well: usually name, email, one qualifying question and an optional message. Add phone, budget or team size only when the answer changes what your team does next.
Through a webhook, yes. There are no one-click integrations, so a small relay or an automation tool such as Zapier, Make or n8n maps each signed webhook to the CRM's API and creates the contact or lead.
Not from Ikiform. Ikiform emails your team, not the person who filled in the form. Send any acknowledgement from your CRM or email tool, triggered by the webhook, or reply personally from the notification email.
Not with a built-in setting. The email field's allowed domains option is an allowlist, which suits internal forms, not public ones. Accept every address and sort free-mail leads in your relay or CRM instead; plenty of real buyers use a personal address.
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.