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.
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.
- With JavaScript, the page posts the reactive lead object as JSON with $fetch.
- The server route rejects posts from other origins, validates with Zod and forwards the lead with its attribution.
- It answers JSON with the next URL, and navigateTo leaves for Cal.com or the thank-you page.
- A validation failure is thrown with createError, and the page reads the field messages from the error's data.
- Without JavaScript, the browser posts the form URL-encoded to the same route.
- 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.
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.
app/
pages/
contact-sales.vue
contact-sales/thanks.vue
server/
middleware/first-touch.ts
api/lead.post.ts
utils/lead.ts
nuxt.config.ts
.envexport 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 |
IKIFORM_SECRET_KEY in server-only environment variables.Server middleware for the first touch
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/middlewarebefore every request and expects them to return nothing. Returning a value would end the request. - The
acceptcheck limits the cookie to page loads, so API calls and assets skip it. getCookie,setCookieand the other h3 helpers are auto-imported inserver/.- Pages prerendered with
routeRulesornuxt generateare served as files, so the middleware never sees those visits. Keep campaign landing pages server-rendered.
One route, two answers
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");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/utilsare auto-imported in server routes, which is whyLeadSchemaandisQualifiedneed no import. - The same browser
Originheader does two jobs: it blocks cross-site posts, and it's forwarded to Ikiform, because a server-side fetch sends none and Ikiform detects freelocalhosttests from it. createErrorputs the field messages indata, and Nuxt's error response nests them, so the client readserror.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
<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
| 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.
- Run
nuxt dev, open/?utm_source=test&utm_campaign=launch, and check thelead_sourcecookie in dev tools. - On
/contact-sales, leave Timeline empty and submit. The error shows and the other answers stay. - Turn JavaScript off and submit the same way. You come back to
/contact-sales?invalid=timelinewith the field marked. - 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.
- Open Submissions in the dashboard and check
utm_campaign,referrerandfit. - 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
emailfield.
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
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.
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.
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.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.