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

Published 2026-10-25 · https://ikiform.com/guides/lead-capture

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.

Diagram: How a lead gets from your form to the person who answers it

1. A visitor fills in the lead form on your site, and it posts to your Ikiform endpoint.
2. Ikiform checks the honeypot, the spam score and the rate limit, then stores the lead.
3. Seconds later each notification recipient gets an email, and pressing Reply writes to the lead.
4. The lead shows up in the dashboard, where you can search, filter and export it.
5. Ikiform sends a signed submission.created webhook.
6. A relay or an automation tool posts the lead to team chat and creates the contact in your CRM.

Email is the fastest path with no setup. The webhook adds the places your team already watches.

## 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](https://ikiform.com/alternatives/typeform) or [Tally](https://ikiform.com/alternatives/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.

Form: Talk to sales

- **Name** (`name`, text, required)
- **Work email** (`email`, email, required)
- **Company** (`company`, text)
- **What can we help with?** (`interest`, select, required)
- **Anything we should know?** (`message`, textarea)

Paste this definition on the form's **Fields** tab and Ikiform enforces it on the server. The [contact form template](https://ikiform.com/forms/contact-form) has a lighter version with a topic instead of an interest.

| 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](https://ikiform.com/forms/quote-request) 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.

**HTML**

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.

`contact-sales.html`:

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

`lead-form.js`:

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

**Next.js**

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

`app/contact-sales/actions.ts`:

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

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

```tsx
"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](https://ikiform.com/guides/lead-capture/nextjs.md).

**Astro**

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.

`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 = {
  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] };
    },
  }),
};
```

`src/pages/contact-sales.astro`:

```astro
---
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](https://ikiform.com/guides/lead-capture/astro.md).

**TanStack Start**

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.

`src/routes/contact-sales.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 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](https://ikiform.com/guides/lead-capture/tanstack-start.md).

**React Router**

The route action forwards the post and returns field errors with Ikiform's status code through `data()`. The component reads them from `actionData`.

`app/routes/contact-sales.tsx`:

```tsx
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](https://ikiform.com/guides/lead-capture/react-router.md).

**SvelteKit**

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.

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

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

`src/routes/contact-sales/+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 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](https://ikiform.com/guides/lead-capture/sveltekit.md).

**Nuxt**

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.

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

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

`nuxt.config.ts`:

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

`app/components/LeadForm.vue`:

```vue
<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](https://ikiform.com/guides/lead-capture/nuxt.md).

**React**

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.

`src/LeadForm.tsx`:

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

**Vue**

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.

`src/components/LeadForm.vue`:

```vue
<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_xxxxxxxxxxxxxxxx` with your form's id from the dashboard.
- The hidden `_gotcha` input is the honeypot. People never see it; bots that fill in every input mark themselves as spam.
- The hidden `page` input records which page the lead came from: pricing, a feature page, a campaign landing page. If you define fields, add `page` as an optional text field, or the post is refused with `Unknown field`.
- The server versions forward the visitor's `Origin` header, because a server-side fetch sends none and Ikiform detects free `localhost` tests from it.
- The plain HTML form needs **Redirect after submit**, or a `_redirect` input on one of your **Allowed domains**, to land on your own thank-you page.

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

The docs cover each framework's setup in depth: [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), [posting from a server](https://ikiform.com/docs/guides/server.md) and [plain HTML](https://ikiform.com/docs/html.md).

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

Diagram: The minutes between a lead's submit and your first reply

1. The lead submits the form.
2. Ikiform stores it and answers the browser in well under a second.
3. The notification email and the webhook go out from a queue seconds later.
4. Someone on the team presses Reply in their inbox, or the mailto link in chat, and the reply goes to the lead.

Everything up to the email takes seconds. The last arrow is the one you control, and the one that wins deals.

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

> Notification emails are capped at 50 per form per hour, or 10 while a workspace is younger than 7 days, then one digest for the rest of the hour. A normal lead form never gets close. If you run a campaign that might, route leads to chat by webhook as well.

### 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](https://ikiform.com/blog/form-submissions-to-slack.md), [Microsoft Teams](https://ikiform.com/blog/form-submissions-to-microsoft-teams.md) or [Discord](https://ikiform.com/blog/form-submissions-to-discord.md). 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:

`app/api/leads/route.ts`:

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

- `verifyWebhook` checks the signature and timestamp, so nobody else can post fake leads into your channels.
- A `502` when Slack is down makes Ikiform retry the same delivery, with the same `webhook-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](https://ikiform.com/docs/spam.md#scoring) 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:

`attribution.html`:

```html
<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](https://ikiform.com/docs/webhooks.md). 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](https://ikiform.com/docs/webhooks.md), directly or through an automation tool such as [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md). 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](https://ikiform.com/guides/lead-capture/leads-to-crm.md) walks through both, including duplicates and retries. Until you need a CRM, the dashboard and a weekly [CSV export](https://ikiform.com/docs/guides/export.md) 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 form](https://ikiform.com/guides/lead-capture/demo-request.md): A 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 form](https://ikiform.com/guides/lead-capture/quote-request.md): A 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 form](https://ikiform.com/guides/lead-capture/consultation-request.md): A 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 form](https://ikiform.com/guides/lead-capture/project-inquiry.md): A 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 Pipedrive](https://ikiform.com/guides/lead-capture/leads-to-crm.md): Map 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 form](https://ikiform.com/guides/lead-capture/nextjs.md): A 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 form](https://ikiform.com/guides/lead-capture/astro.md): An 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 form](https://ikiform.com/guides/lead-capture/tanstack-start.md): A 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 form](https://ikiform.com/guides/lead-capture/react-router.md): A 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 form](https://ikiform.com/guides/lead-capture/sveltekit.md): A 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 form](https://ikiform.com/guides/lead-capture/nuxt.md): A 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](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. Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
3. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
4. 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.
5. If you added a webhook, open the endpoint in **Webhooks**. The delivery shows `succeeded` with your server's answer.
6. 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.
7. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.
8. 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

### What is a lead capture form?

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.

### How do I get lead form submissions to my email?

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.

### How many fields should a lead capture form have?

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.

### Can Ikiform send leads to HubSpot, Pipedrive or Salesforce?

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.

### Does the lead get a confirmation email?

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.

### Can I block free email addresses like Gmail?

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.

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

- [Demo request form](https://ikiform.com/guides/lead-capture/demo-request.md): A 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 form](https://ikiform.com/guides/lead-capture/quote-request.md): A 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 form](https://ikiform.com/guides/lead-capture/consultation-request.md): A 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 form](https://ikiform.com/guides/lead-capture/project-inquiry.md): A 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 Pipedrive](https://ikiform.com/guides/lead-capture/leads-to-crm.md): Map 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 form](https://ikiform.com/guides/lead-capture/nextjs.md): A 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 form](https://ikiform.com/guides/lead-capture/astro.md): An 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 form](https://ikiform.com/guides/lead-capture/tanstack-start.md): A 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 form](https://ikiform.com/guides/lead-capture/react-router.md): A 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 form](https://ikiform.com/guides/lead-capture/sveltekit.md): A 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 form](https://ikiform.com/guides/lead-capture/nuxt.md): A 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.
- [Newsletter signup form](https://ikiform.com/guides/newsletter-signup.md): Build a newsletter signup form in your own markup, record consent, block bot signups and pass each subscriber to Kit, MailerLite, beehiiv or any tool.
- [HTML form submission](https://ikiform.com/guides/html-form-submission.md): What a browser does when a form is submitted: which fields it sends, how it encodes them, what the server should answer, and how Ikiform answers.
- [Event registration](https://ikiform.com/guides/event-registration.md): Take RSVPs on your own event page: the form, guest limits, an honest headcount, closing when full and a check-in list, without a ticketing app.
