# Quote request form: HTML, React and Next.js code

> A quote request form with service, budget and timeline as fixed choices, so every lead arrives in the same shape for your CRM or sales channel.

Updated 2026-10-24 · https://ikiform.com/forms/quote-request

## Copy the code

Every version below is generated from the same field definition. Replace `frm_xxxxxxxxxxxxxxxx` with your form id, or enter it in the **Form ID** box and the code updates.

**HTML** (`quote-request.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" maxlength="120" required />
  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="company">Company</label>
  <input id="company" name="company" type="text" maxlength="200" />
  <label for="phone">Phone</label>
  <input id="phone" name="phone" type="tel" autocomplete="tel" />
  <label for="service">What do you need?</label>
  <select id="service" name="service" required>
    <option value="">Choose one</option>
    <option value="design">Design</option>
    <option value="development">Development</option>
    <option value="consulting">Consulting</option>
    <option value="other">Something else</option>
  </select>
  <label for="budget">Budget</label>
  <select id="budget" name="budget">
    <option value="">Choose one</option>
    <option value="under_5k">Under $5,000</option>
    <option value="5k_20k">$5,000 to $20,000</option>
    <option value="20k_50k">$20,000 to $50,000</option>
    <option value="over_50k">Over $50,000</option>
  </select>
  <fieldset>
    <legend>When do you want to start?</legend>
    <label><input type="radio" name="timeline" value="now" /> As soon as possible</label>
    <label><input type="radio" name="timeline" value="quarter" /> In the next three months</label>
    <label><input type="radio" name="timeline" value="later" /> Later</label>
  </fieldset>
  <label for="details">Project details</label>
  <textarea id="details" name="details" rows="6" minlength="20" maxlength="8000" required aria-describedby="details-help"></textarea>
  <p id="details-help">What you're building, who it's for and anything we should know.</p>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Request a quote</button>
</form>
```

**React** (`QuoteRequestForm.tsx`)

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

type Status = "idle" | "sending" | "sent" | "error";

export function QuoteRequestForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>Thanks. We'll send a quote within two working days.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required />
      {errors.name ? <p>{errors.name[0]}</p> : null}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" type="text" maxLength={200} />
      {errors.company ? <p>{errors.company[0]}</p> : null}
      <label htmlFor="phone">Phone</label>
      <input id="phone" name="phone" type="tel" autoComplete="tel" />
      {errors.phone ? <p>{errors.phone[0]}</p> : null}
      <label htmlFor="service">What do you need?</label>
      <select id="service" name="service" required>
        <option value="">Choose one</option>
        <option value="design">Design</option>
        <option value="development">Development</option>
        <option value="consulting">Consulting</option>
        <option value="other">Something else</option>
      </select>
      {errors.service ? <p>{errors.service[0]}</p> : null}
      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget">
        <option value="">Choose one</option>
        <option value="under_5k">Under $5,000</option>
        <option value="5k_20k">$5,000 to $20,000</option>
        <option value="20k_50k">$20,000 to $50,000</option>
        <option value="over_50k">Over $50,000</option>
      </select>
      {errors.budget ? <p>{errors.budget[0]}</p> : null}
      <fieldset>
        <legend>When do you want to start?</legend>
        <label><input type="radio" name="timeline" value="now" /> As soon as possible</label>
        <label><input type="radio" name="timeline" value="quarter" /> In the next three months</label>
        <label><input type="radio" name="timeline" value="later" /> Later</label>
      </fieldset>
      {errors.timeline ? <p>{errors.timeline[0]}</p> : null}
      <label htmlFor="details">Project details</label>
      <textarea id="details" name="details" rows={6} minLength={20} maxLength={8000} required aria-describedby="details-help" />
      <p id="details-help">What you're building, who it's for and anything we should know.</p>
      {errors.details ? <p>{errors.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Request a quote"}</button>
    </form>
  );
}
```

**JavaScript** (`quote-request.js`)

```js
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  name: "Name",
  email: "Work email",
  company: "Company",
  phone: "Phone",
  service: "What do you need?",
  budget: "Budget",
  timeline: "When do you want to start?",
  details: "Project details",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  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) {
      form.replaceWith("Thanks. We'll send a quote within two working days.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});
```

- **HTML** needs no JavaScript. After a successful post the browser is redirected to the form's redirect URL, or to Ikiform's hosted thank-you page.
- **React** uses the SDK: `npm install @ikiform/sdk`. Field errors from the server appear under each field.
- **JavaScript** enhances the HTML form with `fetch`, so the visitor stays on the page.
- The hidden `_gotcha` input is the honeypot. Leave it empty and hidden.

> If you set the form's spam check to **Ikiform**, the HTML form keeps working with no change and the server action skips the check because it sends a secret key. The React and JavaScript versions must get a token before each submit: call `challenge(FORM_ID)` from `@ikiform/sdk` and pass it to `submit` as `turnstileToken`, or add it to the `fetch` body as `cf-turnstile-response`. Without it the post is refused with `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## The fields

Paste this definition on the form's **Fields** tab in the dashboard. Ikiform then checks every post against it on the server, refuses bad data with `422 validation_failed`, refuses any field that isn't listed, and stores typed values.

| Field id | Type | Required | Accepts |
| --- | --- | --- | --- |
| `name` | `text` | Yes | Up to 120 characters |
| `email` | `email` | Yes | One valid address |
| `company` | `text` | No | Up to 200 characters |
| `phone` | `phone` | No | 6 to 15 digits |
| `service` | `select` | Yes | One of: `design`, `development`, `consulting`, `other` |
| `budget` | `select` | No | One of: `under_5k`, `5k_20k`, `20k_50k`, `over_50k` |
| `timeline` | `radio` | No | One of: `now`, `quarter`, `later` |
| `details` | `textarea` | Yes | 20 to 8,000 characters |

`Field definition`:

```json
{
  "version": 1,
  "title": "Quote request",
  "fields": [
    {
      "id": "name",
      "type": "text",
      "label": "Name",
      "required": true,
      "validation": {
        "max_length": 120
      }
    },
    {
      "id": "email",
      "type": "email",
      "label": "Work email",
      "required": true
    },
    {
      "id": "company",
      "type": "text",
      "label": "Company",
      "validation": {
        "max_length": 200
      }
    },
    {
      "id": "phone",
      "type": "phone",
      "label": "Phone"
    },
    {
      "id": "service",
      "type": "select",
      "label": "What do you need?",
      "required": true,
      "options": [
        {
          "value": "design",
          "label": "Design"
        },
        {
          "value": "development",
          "label": "Development"
        },
        {
          "value": "consulting",
          "label": "Consulting"
        },
        {
          "value": "other",
          "label": "Something else"
        }
      ]
    },
    {
      "id": "budget",
      "type": "select",
      "label": "Budget",
      "options": [
        {
          "value": "under_5k",
          "label": "Under $5,000"
        },
        {
          "value": "5k_20k",
          "label": "$5,000 to $20,000"
        },
        {
          "value": "20k_50k",
          "label": "$20,000 to $50,000"
        },
        {
          "value": "over_50k",
          "label": "Over $50,000"
        }
      ]
    },
    {
      "id": "timeline",
      "type": "radio",
      "label": "When do you want to start?",
      "options": [
        {
          "value": "now",
          "label": "As soon as possible"
        },
        {
          "value": "quarter",
          "label": "In the next three months"
        },
        {
          "value": "later",
          "label": "Later"
        }
      ]
    },
    {
      "id": "details",
      "type": "textarea",
      "label": "Project details",
      "required": true,
      "rows": 6,
      "help_text": "What you're building, who it's for and anything we should know.",
      "validation": {
        "min_length": 20,
        "max_length": 8000
      }
    }
  ],
  "settings": {
    "submit_label": "Request a quote",
    "success_message": "Thanks. We'll send a quote within two working days."
  }
}
```

## Next.js server action

To keep the form working without JavaScript in a Next.js app, post from a server action with a secret key. Create the key under **API keys** with the `submissions:write` scope and keep it out of the browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/quote-request/actions.ts`:

```ts
"use server";

export interface QuoteRequestFormState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string[]>;
}

export async function sendQuoteRequestForm(_previous: QuoteRequestFormState, formData: FormData): Promise<QuoteRequestFormState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks. We'll send a quote within two working days." };
  const values = Object.fromEntries([...new Set(body.keys())].map((name) => [name, body.getAll(name)]));
  return { ok: false, message: result.error.message, fields: result.error.fields, values };
}
```

`app/quote-request/form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type QuoteRequestFormState, sendQuoteRequestForm } from "./actions";

const initial: QuoteRequestFormState = { ok: false, message: "" };

export function QuoteRequestForm() {
  const [state, action, pending] = useActionState(sendQuoteRequestForm, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required defaultValue={state.values?.name?.[0]} />
      {state.fields?.name ? <p>{state.fields.name[0]}</p> : null}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email?.[0]} />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" type="text" maxLength={200} defaultValue={state.values?.company?.[0]} />
      {state.fields?.company ? <p>{state.fields.company[0]}</p> : null}
      <label htmlFor="phone">Phone</label>
      <input id="phone" name="phone" type="tel" autoComplete="tel" defaultValue={state.values?.phone?.[0]} />
      {state.fields?.phone ? <p>{state.fields.phone[0]}</p> : null}
      <label htmlFor="service">What do you need?</label>
      <select id="service" name="service" required defaultValue={state.values?.service?.[0]}>
        <option value="">Choose one</option>
        <option value="design">Design</option>
        <option value="development">Development</option>
        <option value="consulting">Consulting</option>
        <option value="other">Something else</option>
      </select>
      {state.fields?.service ? <p>{state.fields.service[0]}</p> : null}
      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" defaultValue={state.values?.budget?.[0]}>
        <option value="">Choose one</option>
        <option value="under_5k">Under $5,000</option>
        <option value="5k_20k">$5,000 to $20,000</option>
        <option value="20k_50k">$20,000 to $50,000</option>
        <option value="over_50k">Over $50,000</option>
      </select>
      {state.fields?.budget ? <p>{state.fields.budget[0]}</p> : null}
      <fieldset>
        <legend>When do you want to start?</legend>
        <label><input type="radio" name="timeline" value="now" defaultChecked={state.values?.timeline?.includes("now")} /> As soon as possible</label>
        <label><input type="radio" name="timeline" value="quarter" defaultChecked={state.values?.timeline?.includes("quarter")} /> In the next three months</label>
        <label><input type="radio" name="timeline" value="later" defaultChecked={state.values?.timeline?.includes("later")} /> Later</label>
      </fieldset>
      {state.fields?.timeline ? <p>{state.fields.timeline[0]}</p> : null}
      <label htmlFor="details">Project details</label>
      <textarea id="details" name="details" rows={6} minLength={20} maxLength={8000} required aria-describedby="details-help" defaultValue={state.values?.details?.[0]} />
      <p id="details-help">What you're building, who it's for and anything we should know.</p>
      {state.fields?.details ? <p>{state.fields.details[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Request a quote"}</button>
    </form>
  );
}
```

> A server action posts from your server's IP, so the per-visitor rate limit counts every visitor together. Use the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for forms you send through a server.

## Set it up

1. Create a form in the [dashboard](https://ikiform.com/dashboard) and copy its id.
2. Paste the field definition on the **Fields** tab.
3. Add your site under **Allowed domains**, plus `localhost` while you develop.
4. Set the rate limit to **Strict, 2 every 10 minutes per visitor** if visitors post from the browser. Leads come one at a time. Anything faster is a double click or a script. With the server action, every post comes from your server's IP, so choose Relaxed or Custom instead.
5. Paste the code, send a test and check it arrives in the dashboard.

## Fixed choices make leads sortable

`service`, `budget` and `timeline` only accept the listed values. A post with any other value is refused with `422 validation_failed`, so the data that reaches your CRM or spreadsheet is always one of a few known strings. Change the labels freely; keep the values stable once automations depend on them.

| Field | Stored as | Example |
| --- | --- | --- |
| `service` | One option value | `development` |
| `budget` | One option value, or left out | `5k_20k` |
| `timeline` | One option value, or left out | `quarter` |
| `phone` | The number as typed, 6 to 15 digits | `+1 415 555 0123` |

Set `e164: true` on the phone field if your CRM wants international format; `+1 (415) 555-0123` is then stored as `+14155550123`, and numbers without a country code are refused.

## Answer fast

Quote requests go cold quickly. Send them to a chat channel the team watches, and to the CRM or sheet you track deals in. Every webhook delivery is signed and retried for about 3 days if your endpoint is down, so a lead is never lost to a failed request.

> Reply-to works here too: the email field is `email`, so replying to the notification email writes to the lead. Label it however you like; only the id matters.

## Where to send it

Every submission can go to a signed [webhook](https://ikiform.com/docs/webhooks.md). These guides cover the tools that suit this form, with the relay code:

- [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md)
- [Send form submissions to Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)
- [Send form submissions to Make](https://ikiform.com/blog/form-submissions-to-make.md)
- [Send form submissions to Airtable](https://ikiform.com/blog/form-submissions-to-airtable.md)
- [Send form submissions to a Notion database](https://ikiform.com/blog/form-submissions-to-notion.md)
- [Receive form submissions on your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md)

## Questions

### Can I send quote requests to my CRM?

Yes, through a webhook. Ikiform posts each submission as signed JSON, which Zapier, Make, n8n or your own server can pass to any CRM with an API.

### Why are budget and timeline select fields instead of free text?

Ikiform only accepts the listed option values for select and radio fields, so every lead uses the same few strings. That makes filtering, routing and reporting reliable.

### What happens if someone submits twice?

The Strict rate limit allows 2 every 10 minutes per visitor. An identical repeat within 24 hours also adds 3 to the spam score, which on its own is below the spam threshold of 5.
