Formulaire de capture de leads8 min de lecture
Créer un formulaire de leads qualifiant avec Nuxt
Un formulaire de leads Nuxt : un middleware serveur enregistre les UTM du premier contact, une route serveur validée par Zod sert fetch et les envois classiques, et les bons leads réservent un appel.
Enregistrez les tags UTM et le referrer dans un middleware serveur Nitro à la première requête de page, dans un cookie httpOnly. Envoyez le formulaire à une seule route serveur qui valide avec Zod, transmet le lead avec l'attribution à votre backend de formulaires, et répond en JSON à $fetch ou par un 303 à un envoi de formulaire classique. Côté client, navigateTo(url, { external: true }) envoie les leads qualifiés vers votre outil de réservation.
L'onglet Nuxt du guide principal envoie un lead via une route serveur avec $fetch et affiche une ligne de remerciement. Cette page construit la version commerciale : taille d'entreprise, budget et calendrier vérifiés par le serveur, la campagne derrière chaque lead, un passage vers votre agenda, et un chemin qui fonctionne même quand JavaScript ne se charge pas.
Ce que change Nuxt
Nuxt n'a pas de form actions. Un formulaire envoie à une route serveur Nitro comme à n'importe quel endpoint, ce qui vous laisse deux transports à servir : $fetch avec du JSON depuis la page hydratée, et un envoi URL-encoded depuis le formulaire HTML brut. Le readBody de h3 analyse les deux : une seule route peut donc servir les deux, à condition de répondre à chacun à sa façon.
Cette commodité a un coût : une route qui accepte des corps URL-encoded accepte aussi les simples envois de formulaire cross-site. La documentation de h3 met en garde à ce sujet, et contrairement à SvelteKit ou Next.js, Nuxt n'ajoute aucune vérification d'origine pour vous. La route vérifie elle-même l'Origin.
- Avec JavaScript, la page envoie l'objet réactif du lead en JSON avec $fetch.
- La route serveur rejette les envois d'autres origines, valide avec Zod et transmet le lead avec son attribution.
- Elle répond en JSON avec l'URL suivante, et navigateTo part vers Cal.com ou la page de remerciement.
- Un échec de validation est levé avec createError, et la page lit les messages de champ dans les données de l'erreur.
- Sans JavaScript, le navigateur envoie le formulaire en URL-encoded à la même route.
- La route fait les mêmes vérifications et répond par un 303 : vers l'outil de réservation, la page de remerciement, ou le formulaire avec les noms des champs en erreur.
Mise en place du projet
Partez d'une application Nuxt 4 et installez zod. Déclarez la clé dans runtimeConfig et définissez-la sous le nom NUXT_IKIFORM_SECRET_KEY ; Nuxt mappe la variable à l'exécution quand la route lit useRuntimeConfig(event). La page Nuxt de la documentation couvre la clé et le formulaire.
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: "",
},
});Ajoutez les champs dans l'onglet Fields du formulaire, pour qu'Ikiform refasse la validation et refuse les clés inconnues.
| Id du champ | Type dans Ikiform | Renseigné par |
|---|---|---|
name, email, company | Texte, e-mail, texte | Le visiteur |
company_size | Select : 1_10, 11_50, 51_200, 201_1000, 1000_plus | Le visiteur |
budget | Select : under_10k, 10k_50k, 50k_plus, unsure | Le visiteur |
timeline | Select : this_quarter, next_quarter, later, researching | Le visiteur |
message | Textarea, facultatif | Le visiteur |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Texte, facultatif | Votre serveur, depuis la première visite |
fit | Select : qualified, nurture | Votre serveur, après validation |
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Un middleware serveur pour le premier contact
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 exécute les fichiers de
server/middlewareavant chaque requête et s'attend à ce qu'ils ne renvoient rien. Renvoyer une valeur terminerait la requête. - La vérification
acceptlimite le cookie aux chargements de page : les appels d'API et les assets y échappent. getCookie,setCookieet les autres helpers h3 sont importés automatiquement dansserver/.- Les pages prérendues avec
routeRulesounuxt generatesont servies en fichiers : le middleware ne voit donc jamais ces visites. Gardez les landing pages de campagne rendues côté serveur.
Une route, deux réponses
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);
});- Les exports de
server/utilssont importés automatiquement dans les routes serveur, c'est pourquoiLeadSchemaetisQualifiedn'ont pas besoin d'import. - Le même en-tête
Origindu navigateur sert à deux choses : il bloque les envois cross-site, et il est transmis à Ikiform, car un fetch côté serveur n'en envoie aucun, et c'est grâce à lui qu'Ikiform détecte les tests gratuits depuislocalhost. createErrorplace les messages de champ dansdata, et la réponse d'erreur de Nuxt les imbrique : le client lit doncerror.data.data.fields.- Un envoi classique ne peut pas renvoyer d'état sans session : un échec redirige donc avec les noms des champs en erreur dans l'URL. Le visiteur retape ; c'est le chemin de repli.
La 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 prend le relais une fois Nuxt hydraté, et l'objet réactif lead garde chaque valeur malgré une erreur. navigateTo refuse une URL absolue sauf si external est défini : l'option suit donc l'URL, externe pour Cal.com, navigation côté client pour la page de remerciement. Avant l'hydratation, l'attribut action envoie à la même route et la page lit ?invalid= pour marquer les champs.
Honeypot et vérification humaine
Le honeypot fait partie du schéma et est transmis : Ikiform stocke donc un honeypot rempli comme spam, et la route l'envoie vers la page de remerciement. La clé secrète contourne la vérification humaine d'Ikiform ; vérifiez un token Turnstile dans la route si vous en avez besoin, comme dans Turnstile sur un formulaire.
Notes de déploiement
| Hébergeur | À savoir |
|---|---|
| Vercel | Nitro détecte Vercel et génère des fonctions. Définissez NUXT_IKIFORM_SECRET_KEY dans les variables d'environnement. |
| Netlify | Nitro détecte aussi Netlify. Définissez la variable dans les réglages du site. |
| Cloudflare | Buildez avec le preset cloudflare_module et stockez la variable avec wrangler secret put. Lire useRuntimeConfig(event) avec l'événement la récupère à l'exécution. |
Vérifiez que tout fonctionne
Les envois depuis nuxt dev portent une origine localhost. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les tarifs.
- Lancez
nuxt dev, ouvrez/?utm_source=test&utm_campaign=launchet vérifiez le cookielead_sourcedans les outils de développement. - Sur
/contact-sales, laissez Timeline vide et envoyez. L'erreur s'affiche et les autres réponses restent. - Désactivez JavaScript et envoyez de la même façon. Vous revenez sur
/contact-sales?invalid=timelineavec le champ marqué. - Envoyez comme un lead qualifié : 11 à 50 personnes, plus de 50 k$, ce trimestre. Vous arrivez sur Cal.com avec le nom et l'e-mail remplis.
- Ouvrez Submissions dans le tableau de bord et vérifiez
utm_campaign,referreretfit. - Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ
email.
Pièges
| Problème | Que faire |
|---|---|
navigateTo lève une exception quand un lead est qualifié | external: true manque. navigateTo refuse sans lui les URL absolues vers d'autres sites. |
| La clé est vide en production | La variable doit s'appeler NUXT_IKIFORM_SECRET_KEY, et la route doit appeler useRuntimeConfig(event) pour qu'elle soit lue à l'exécution. |
403 Cross-site post derrière un proxy | Le proxy change le Host que voit la route. Transmettez l'hôte d'origine, ou comparez plutôt l'origine à votre domaine public. |
| Le cookie est posé sur des appels d'API ou des images | La vérification accept a été supprimée. Limitez le middleware aux chargements de page. |
422 validation_failed | L'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ. |
Questions
Côté client, appelez navigateTo(url, { external: true }) ; sans cette option, il lève une exception pour un autre site. Dans une route serveur, sendRedirect(event, url, 303) répond à un envoi de formulaire classique par une redirection.
Oui. readBody analyse les corps JSON et URL-encoded : vérifiez donc le content type, et répondez en JSON à $fetch et par une redirection 303 à un envoi natif. Vérifiez aussi l'en-tête Origin, car la route accepte désormais les envois de formulaire cross-site.
Dans un fichier sous server/middleware. Il s'exécute avant chaque requête serveur, peut lire les paramètres d'URL et le referrer, et pose un cookie httpOnly que la route des leads lit plus tard. Il ne s'exécute pas pour les pages prérendues servies en fichiers.
Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.