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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/lead-capture/nuxt

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

Schéma : Une seule route serveur Nuxt qui répond à un formulaire hydraté et à un envoi de formulaire classique

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](https://ikiform.com/fr/docs/guides/nuxt.md) couvre la clé et le formulaire.

`fichiers`:

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

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 |

> 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](https://ikiform.com/fr/docs/rate-limits.md) 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`:

```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`:

```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);
});
```

- 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`:

```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](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/fr/pricing).

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

### Comment rediriger vers une URL externe dans Nuxt ?

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.

### Une seule route serveur Nuxt peut-elle gérer à la fois $fetch et un envoi de formulaire classique ?

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.

### Où placer le suivi UTM dans Nuxt ?

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.

### Ikiform est-il gratuit ?

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.

## Autres guides

- [Formulaire de capture de leads](https://ikiform.com/fr/guides/lead-capture.md): Un formulaire de capture de leads sur votre propre site, qui vous écrit avec le lead en adresse de réponse, alerte le chat de l'équipe et alimente votre CRM, pour répondre tant que le lead est encore là.
- [Formulaire de leads SvelteKit](https://ikiform.com/fr/guides/lead-capture/sveltekit.md): Un formulaire de leads SvelteKit : le hook handle enregistre les UTM du premier contact, une form action valide avec Valibot et redirect() envoie les leads qualifiés vers un agenda.
- [Formulaire de leads Next.js](https://ikiform.com/fr/guides/lead-capture/nextjs.md): Un formulaire de leads Next.js App Router qui valide avec Zod dans une server action, garde les UTM du premier contact dans un cookie et envoie les leads qualifiés vers votre agenda.
- [Formulaire de demande de démo](https://ikiform.com/fr/guides/lead-capture/demo-request.md): Un formulaire de réservation de démo qui stocke le lead, puis envoie le visiteur vers Cal.com ou Calendly avec son nom et son e-mail déjà remplis.
- [Formulaire de demande de devis](https://ikiform.com/fr/guides/lead-capture/quote-request.md): Un formulaire de demande de devis qui reçoit les briefs sous forme de liens partagés plutôt qu'en pièces jointes, fixe une échéance à chaque demande et garde les leads riches en liens hors du spam.
- [Formulaire de demande de consultation](https://ikiform.com/fr/guides/lead-capture/consultation-request.md): Un formulaire de demande de consultation pour comptables, avocats, coachs et cabinets, qui demande comment et quand appeler, et garde les détails sensibles pour l'appel.
