Lead capture form

Lead capture form7 min read

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.

Preet SutharFounder, Ikiform

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

One Nuxt server route answering a hydrated form and a plain form postwith JavaScriptwithout JavaScript$fetchJSON bodylead.post.tsorigin check,Zod{ next }JSON answernavigateToexternal: trueerror.data.data.fieldsshown undereach field<formaction>urlencodedlead.post.tssamevalidationsendRedirect303Cal.com or?invalid=POSTcreateError(400)POSTOne Nuxt server route answering a hydrated form and a plain form postwith JavaScriptwithout JavaScript$fetchJSON bodylead.post.tsorigin check, Zod{ next }JSON answernavigateToexternal: trueerror.data.data.fieldsshown undereach field<form action>urlencodedlead.post.tssame validationsendRedirect303Cal.com or ?invalid=POSTPOSTcreateError(400)
  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 covers the key and the form.

files
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
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});

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

Field idType in IkiformSet by
name, email, companyText, email, textThe visitor
company_sizeSelect: 1_10, 11_50, 51_200, 201_1000, 1000_plusThe visitor
budgetSelect: under_10k, 10k_50k, 50k_plus, unsureThe visitor
timelineSelect: this_quarter, next_quarter, later, researchingThe visitor
messageTextarea, optionalThe visitor
utm_source, utm_medium, utm_campaign, referrer, landing_pageText, optionalYour server, from the first visit
fitSelect: qualified, nurtureYour 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 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
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
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
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
<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.

Deploy notes

HostWhat to know
VercelNitro detects Vercel and builds functions. Set NUXT_IKIFORM_SECRET_KEY in environment variables.
NetlifyNitro detects Netlify too. Set the variable in site settings.
CloudflareBuild 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.

  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

ProblemWhat to do
navigateTo throws when a lead qualifiesexternal: true is missing. navigateTo refuses absolute URLs to other sites without it.
The key is empty in productionThe 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 proxyThe 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 imagesThe accept check was removed. Keep the middleware to page loads.
422 validation_failedThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.