Formulaire de capture de leads

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.

Preet SutharFondateur, Ikiform

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.

Une seule route serveur Nuxt qui répond à un formulaire hydraté et à un envoi de formulaire classiqueavec JavaScriptsans JavaScript$fetchcorps JSONlead.post.tsvérif.d'origine, Zod{ next }réponse JSONnavigateToexternal: trueerror.data.data.fieldsaffichées souschaque champ<formaction>urlencodedlead.post.tsmêmevalidationsendRedirect303Cal.comou ?invalid=POSTcreateError(400)POSTUne seule route serveur Nuxt qui répond à un formulaire hydraté et à un envoi de formulaire classiqueavec JavaScriptsans JavaScript$fetchcorps JSONlead.post.tsvérif. d'origine, Zod{ next }réponse JSONnavigateToexternal: trueerror.data.data.fieldsaffichéessous chaquechamp<form action>urlencodedlead.post.tsmême validationsendRedirect303Cal.comou ?invalid=POSTPOSTcreateError(400)
  1. Avec JavaScript, la page envoie l'objet réactif du lead en JSON avec $fetch.
  2. La route serveur rejette les envois d'autres origines, valide avec Zod et transmet le lead avec son attribution.
  3. Elle répond en JSON avec l'URL suivante, et navigateTo part vers Cal.com ou la page de remerciement.
  4. Un échec de validation est levé avec createError, et la page lit les messages de champ dans les données de l'erreur.
  5. Sans JavaScript, le navigateur envoie le formulaire en URL-encoded à la même route.
  6. 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.
C'est le content type qui décide de la réponse, pas la route. La validation et l'envoi à Ikiform sont écrits une seule fois.

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.

fichiers
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: "",
  },
});

Ajoutez les champs dans l'onglet Fields du formulaire, pour qu'Ikiform refasse la validation et refuse les clés inconnues.

Id du champType dans IkiformRenseigné par
name, email, companyTexte, e-mail, texteLe visiteur
company_sizeSelect : 1_10, 11_50, 51_200, 201_1000, 1000_plusLe visiteur
budgetSelect : under_10k, 10k_50k, 50k_plus, unsureLe visiteur
timelineSelect : this_quarter, next_quarter, later, researchingLe visiteur
messageTextarea, facultatifLe visiteur
utm_source, utm_medium, utm_campaign, referrer, landing_pageTexte, facultatifVotre serveur, depuis la première visite
fitSelect : qualified, nurtureVotre serveur, après validation
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Un middleware serveur pour le premier contact

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 exécute les fichiers de server/middleware avant chaque requête et s'attend à ce qu'ils ne renvoient rien. Renvoyer une valeur terminerait la requête.
  • La vérification accept limite le cookie aux chargements de page : les appels d'API et les assets y échappent.
  • getCookie, setCookie et les autres helpers h3 sont importés automatiquement dans server/.
  • Les pages prérendues avec routeRules ou nuxt generate sont 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

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);
});
  • Les exports de server/utils sont importés automatiquement dans les routes serveur, c'est pourquoi LeadSchema et isQualified n'ont pas besoin d'import.
  • Le même en-tête Origin du 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 depuis localhost.
  • createError place les messages de champ dans data, et la réponse d'erreur de Nuxt les imbrique : le client lit donc error.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

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 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
VercelNitro détecte Vercel et génère des fonctions. Définissez NUXT_IKIFORM_SECRET_KEY dans les variables d'environnement.
NetlifyNitro détecte aussi Netlify. Définissez la variable dans les réglages du site.
CloudflareBuildez 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.

  1. Lancez nuxt dev, ouvrez /?utm_source=test&utm_campaign=launch et vérifiez le cookie lead_source dans les outils de développement.
  2. Sur /contact-sales, laissez Timeline vide et envoyez. L'erreur s'affiche et les autres réponses restent.
  3. Désactivez JavaScript et envoyez de la même façon. Vous revenez sur /contact-sales?invalid=timeline avec le champ marqué.
  4. 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.
  5. Ouvrez Submissions dans le tableau de bord et vérifiez utm_campaign, referrer et fit.
  6. 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èmeQue 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 productionLa 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 proxyLe 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 imagesLa vérification accept a été supprimée. Limitez le middleware aux chargements de page.
422 validation_failedL'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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.