# Build a qualifying lead form in Nuxt

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

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

Record UTM tags and the referrer in Nitro server middleware on the first page request, in an httpOnly cookie. Post the form to one server route that validates with Zod, forwards the lead with the attribution to your form backend, and answers JSON to `$fetch` or a `303` to a plain form post. On the client, `navigateTo(url, { external: true })` sends qualified leads to your scheduler.

The [Nuxt tab on the main guide](https://ikiform.com/guides/lead-capture.md#form) sends a lead through a server route with `$fetch` and shows a thank-you line. This page builds the sales version: company size, budget and timeline checked by the server, the campaign behind each lead, a hand-off to your calendar, and a path that still works when JavaScript doesn't load.

## What Nuxt changes

Nuxt has no form actions. A form posts to a Nitro server route like any other endpoint, which leaves you two transports to serve: `$fetch` with JSON from the hydrated page, and a URL-encoded post from the bare HTML form. h3's `readBody` parses both, so one route can serve both, as long as it answers each in its own way.

That convenience has a cost: a route that accepts URL-encoded bodies accepts plain cross-site form posts too. h3's docs warn about this, and unlike SvelteKit or Next.js, Nuxt adds no origin check for you. The route checks the `Origin` itself.

Diagram: One Nuxt server route answering a hydrated form and a plain form post

1. With JavaScript, the page posts the reactive lead object as JSON with $fetch.
2. The server route rejects posts from other origins, validates with Zod and forwards the lead with its attribution.
3. It answers JSON with the next URL, and navigateTo leaves for Cal.com or the thank-you page.
4. A validation failure is thrown with createError, and the page reads the field messages from the error's data.
5. Without JavaScript, the browser posts the form URL-encoded to the same route.
6. The route runs the same checks and answers with a 303: to the scheduler, the thank-you page, or back to the form with the failing field names.

The content type decides the answer, not the route. Validation and the Ikiform post are written once.

## Project setup

Start from a Nuxt 4 app and install `zod`. Declare the key in `runtimeConfig` and set it as `NUXT_IKIFORM_SECRET_KEY`; Nuxt maps the variable at runtime when the route reads `useRuntimeConfig(event)`. The [Nuxt docs page](https://ikiform.com/docs/guides/nuxt.md) covers the key and the form.

`files`:

```text
app/
  pages/
    contact-sales.vue
    contact-sales/thanks.vue
server/
  middleware/first-touch.ts
  api/lead.post.ts
  utils/lead.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

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

Add the fields on the form's **Fields** tab, so Ikiform repeats the validation and refuses unknown keys.

| Field id | Type in Ikiform | Set by |
| --- | --- | --- |
| `name`, `email`, `company` | Text, email, text | The visitor |
| `company_size` | Select: `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | The visitor |
| `budget` | Select: `under_10k`, `10k_50k`, `50k_plus`, `unsure` | The visitor |
| `timeline` | Select: `this_quarter`, `next_quarter`, `later`, `researching` | The visitor |
| `message` | Textarea, optional | The visitor |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | Text, optional | Your server, from the first visit |
| `fit` | Select: `qualified`, `nurture` | Your server, after validation |

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

## Server middleware for the first touch

`server/middleware/first-touch.ts`:

```ts
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export default defineEventHandler((event) => {
  if (event.method !== "GET" || getCookie(event, "lead_source")) return;
  if (!getRequestHeader(event, "accept")?.includes("text/html")) return;

  const url = getRequestURL(event);
  const referer = getRequestHeader(event, "referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
  const source = new URLSearchParams();
  for (const tag of TAGS) source.set(tag, url.searchParams.get(tag)?.slice(0, 100) ?? "");
  if (!external && !TAGS.some((tag) => source.get(tag))) return;

  source.set("referrer", external);
  source.set("landing_page", url.pathname.slice(0, 200));
  setCookie(event, "lead_source", source.toString(), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
});
```

- Nitro runs files in `server/middleware` before every request and expects them to return nothing. Returning a value would end the request.
- The `accept` check limits the cookie to page loads, so API calls and assets skip it.
- `getCookie`, `setCookie` and the other h3 helpers are auto-imported in `server/`.
- Pages prerendered with `routeRules` or `nuxt generate` are served as files, so the middleware never sees those visits. Keep campaign landing pages server-rendered.

## One route, two answers

`server/utils/lead.ts`:

```ts
import { z } from "zod";

export const LeadSchema = z.object({
  name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
  email: z.email("Enter a work email we can reply to."),
  company: z.string().trim().min(1, "Which company is this for?").max(200),
  company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
  budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
  timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
  message: z.string().trim().max(5000).optional(),
  _gotcha: z.string().optional(),
});

export type Lead = z.infer<typeof LeadSchema>;

export const isQualified = (lead: Lead) =>
  lead.company_size !== "1_10" &&
  (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
  (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");
```

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

```ts
import { z } from "zod";

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export default defineEventHandler(async (event) => {
  const origin = getRequestHeader(event, "origin");
  if (origin && new URL(origin).host !== getRequestHost(event)) {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json");
  const parsed = LeadSchema.safeParse(await readBody(event));

  if (!parsed.success) {
    const fields = z.flattenError(parsed.error).fieldErrors;
    if (!wantsJson) return sendRedirect(event, `/contact-sales?invalid=${Object.keys(fields).join(",")}`, 303);
    throw createError({ status: 400, message: "Check the highlighted fields.", data: { fields } });
  }

  const lead = parsed.data;
  const stored = new URLSearchParams(getCookie(event, "lead_source") ?? "");
  const qualified = isQualified(lead);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      ...lead,
      ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
      fit: qualified ? "qualified" : "nurture",
    }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    if (!wantsJson) return sendRedirect(event, "/contact-sales?failed=1", 303);
    throw createError({
      status: response.status,
      message: result?.error?.message ?? "We couldn't send that. Try again.",
      data: { fields: result?.error?.fields ?? {} },
    });
  }

  const next =
    qualified && !lead._gotcha
      ? `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`
      : "/contact-sales/thanks";
  return wantsJson ? { next } : sendRedirect(event, next, 303);
});
```

- Exports from `server/utils` are auto-imported in server routes, which is why `LeadSchema` and `isQualified` need no import.
- The same browser `Origin` header does two jobs: it blocks cross-site posts, and it's forwarded to Ikiform, because a server-side fetch sends none and Ikiform detects free `localhost` tests from it.
- `createError` puts the field messages in `data`, and Nuxt's error response nests them, so the client reads `error.data.data.fields`.
- A plain post can't carry state back without a session, so a failure redirects with the failing field names in the query string. The visitor retypes; it's the fallback path.

## The page

`app/pages/contact-sales.vue`:

```vue
<script setup lang="ts">
const route = useRoute();
const lead = reactive({ name: "", email: "", company: "", company_size: "", budget: "", timeline: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[] | undefined>>({});
const failure = ref(route.query.failed ? "We couldn't send that. Email sales@example.com instead." : "");
const sending = ref(false);

for (const key of String(route.query.invalid ?? "").split(",").filter(Boolean)) {
  errors.value[key] = ["Check this field."];
}

const selects = {
  company_size: ["Company size", { "1_10": "1 to 10", "11_50": "11 to 50", "51_200": "51 to 200", "201_1000": "201 to 1,000", "1000_plus": "More than 1,000" }],
  budget: ["Budget", { under_10k: "Under $10k", "10k_50k": "$10k to $50k", "50k_plus": "More than $50k", unsure: "Not sure yet" }],
  timeline: ["When do you want to start?", { this_quarter: "This quarter", next_quarter: "Next quarter", later: "Later this year", researching: "Researching for now" }],
} as const;

async function send() {
  sending.value = true;
  errors.value = {};
  failure.value = "";
  try {
    const { next } = await $fetch<{ next: string }>("/api/lead", { method: "POST", body: lead });
    await navigateTo(next, { external: next.startsWith("https://") });
  } 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 ?? "We couldn't send that. Try again.";
    sending.value = false;
  }
}
</script>

<template>
  <form action="/api/lead" method="post" @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" required />
    <p v-if="errors.company" role="alert">{{ errors.company[0] }}</p>
    <template v-for="([label, options], id) in selects" :key="id">
      <label :for="id">{{ label }}</label>
      <select :id="id" v-model="lead[id]" :name="id" required>
        <option value="" disabled>Choose one</option>
        <option v-for="(text, value) in options" :key="value" :value="value">{{ text }}</option>
      </select>
      <p v-if="errors[id]" role="alert">{{ errors[id]?.[0] }}</p>
    </template>
    <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>
```

`@submit.prevent` takes over when Nuxt has hydrated, and the reactive `lead` object keeps every value through an error. `navigateTo` refuses an absolute URL unless `external` is set, so the flag follows the URL: external for Cal.com, a client navigation for the thank-you page. Before hydration, the `action` attribute posts to the same route and the page reads `?invalid=` to mark fields.

### Honeypot and human check

The honeypot is in the schema and forwarded, so Ikiform stores a filled one as spam, and the route sends it to the thank-you page. The secret key skips Ikiform's human check; verify a Turnstile token in the route if you need one, as in [Turnstile on a form](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md).

## Deploy notes

| Host | What to know |
| --- | --- |
| Vercel | Nitro detects Vercel and builds functions. Set `NUXT_IKIFORM_SECRET_KEY` in environment variables. |
| Netlify | Nitro detects Netlify too. Set the variable in site settings. |
| Cloudflare | Build with the `cloudflare_module` preset and store the variable with `wrangler secret put`. Reading `useRuntimeConfig(event)` with the event picks it up at runtime. |

## Check it works

Posts from `nuxt dev` carry a `localhost` origin. 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. Run `nuxt dev`, open `/?utm_source=test&utm_campaign=launch`, and check the `lead_source` cookie in dev tools.
2. On `/contact-sales`, leave **Timeline** empty and submit. The error shows and the other answers stay.
3. Turn JavaScript off and submit the same way. You come back to `/contact-sales?invalid=timeline` with the field marked.
4. Submit as a qualified lead: 11 to 50 people, more than $50k, this quarter. You land on Cal.com with name and email filled in.
5. Open **Submissions** in the dashboard and check `utm_campaign`, `referrer` and `fit`.
6. Check the inbox of each notification recipient. Each gets an email with the fields, and **Reply** goes to the submitter when the form has an `email` field.

## Gotchas

| Problem | What to do |
| --- | --- |
| `navigateTo` throws when a lead qualifies | `external: true` is missing. `navigateTo` refuses absolute URLs to other sites without it. |
| The key is empty in production | The variable must be named `NUXT_IKIFORM_SECRET_KEY`, and the route must call `useRuntimeConfig(event)` so it's read at runtime. |
| `403 Cross-site post` behind a proxy | The proxy changes the `Host` the route sees. Forward the original host, or compare the origin with your public domain instead. |
| The cookie is set on API calls or images | The `accept` check was removed. Keep the middleware to page loads. |
| `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. |

## Questions

### How do I redirect to an external URL in Nuxt?

On the client, call navigateTo(url, { external: true }); without the flag it throws for another site. In a server route, sendRedirect(event, url, 303) answers a plain form post with a redirect.

### Can one Nuxt server route handle both $fetch and a plain form post?

Yes. readBody parses JSON and URL-encoded bodies, so check the content type and answer JSON to $fetch and a 303 redirect to a native post. Check the Origin header too, because the route now accepts cross-site form posts.

### Where should UTM tracking live in Nuxt?

In a file under server/middleware. It runs before every server request, can read the query string and referrer, and sets an httpOnly cookie the lead route reads later. It doesn't run for prerendered pages served as files.

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

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