# Créer un formulaire de leads qualifiant avec Next.js

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

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

Capturez les tags de campagne et le referrer dans `proxy.ts` à la première visite, et gardez-les dans un cookie. Validez le lead avec Zod dans une server action, notez-le selon la taille de l'entreprise, le budget et le calendrier, et transmettez-le avec l'attribution à votre backend de formulaires. Appelez ensuite `redirect()` pour envoyer les leads qualifiés vers votre outil de réservation et tous les autres vers une page de remerciement.

L'[onglet Next.js du guide principal](https://ikiform.com/fr/guides/lead-capture.md#form) transmet un lead et affiche une ligne de remerciement. Cette page construit la version commerciale : trois questions de qualification vérifiées sur votre serveur, des données de campagne qui survivent à la navigation du visiteur avant sa conversion, et un passage direct vers votre agenda pour les leads qui méritent un appel.

## Ce que change Next.js

Deux fonctionnalités de Next.js font l'essentiel du travail. `proxy.ts`, le fichier appelé `middleware.ts` avant Next.js 16, s'exécute avant chaque requête de page, même pour les pages statiques. C'est donc le bon endroit pour lire `?utm_source=` et l'en-tête `Referer` lors de la première visite, car au moment où le visiteur atteint **Parler à un commercial**, les deux ont disparu de l'URL. La server action lit ensuite le cookie : la page de contact elle-même peut donc rester statique.

La seconde est `redirect()`. Dans une server action, elle accepte une URL absolue : un lead qualifié arrive donc sur Cal.com sans aucun code client. Avec JavaScript chargé, Next.js navigue côté client ; avant l'hydratation, il répond à l'envoi du formulaire par un `303`.

Schéma : Comment un lead Next.js voyage du premier clic sur une annonce à un appel réservé

1. Le visiteur arrive par un lien de campagne avec des tags UTM ou par le lien d'un autre site.
2. proxy.ts stocke les tags, le referrer et le chemin d'arrivée dans un cookie httpOnly, une seule fois.
3. Les pages de tarifs et de contact restent statiques et viennent du cache.
4. À l'envoi, la server action valide les champs avec Zod et lit le cookie.
5. Elle envoie le lead, l'attribution et une adéquation calculée à Ikiform avec la clé secrète.
6. Un lead qualifié est redirigé vers l'outil de réservation avec nom et e-mail remplis ; les autres vont vers la page de remerciement.
7. Une erreur de validation revient comme état de l'action, avec toutes les valeurs tapées par le visiteur.

Le cookie transporte le premier contact de page en page : la page de contact n'a jamais besoin de lire l'URL.

## Mise en place du projet

Partez de n'importe quel projet App Router sous Next.js 16. Installez `zod`, créez un formulaire dans le tableau de bord et une clé secrète avec le scope `submissions:write` sous **API keys**. La [page Next.js de la documentation](https://ikiform.com/fr/docs/guides/nextjs.md) détaille la clé et la configuration du formulaire.

`fichiers`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

Ajoutez ensuite les champs dans l'onglet **Fields** du formulaire, pour qu'Ikiform les applique aussi. Sans définition, Ikiform stocke tout ce que vous envoyez ; avec une définition, un champ inconnu donne un `422`, ce qui est souhaitable dès que l'attribution est dans le payload.

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

## Attribution au premier contact dans proxy.ts

`proxy.ts`:

```ts
import { type NextRequest, NextResponse } from "next/server";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export function proxy(request: NextRequest) {
  const response = NextResponse.next();
  if (request.cookies.has("lead_source")) return response;

  const url = request.nextUrl;
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href : "";
  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 response;

  source.set("referrer", external.slice(0, 300));
  source.set("landing_page", url.pathname.slice(0, 200));
  response.cookies.set("lead_source", source.toString(), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
```

- Le cookie est posé une fois et jamais écrasé : il enregistre la première campagne, pas le dernier clic interne. Supprimez la vérification `has` si vous préférez le dernier contact.
- Un referrer provenant de votre propre hôte est ignoré. Sans cela, chaque page vue après la première compterait comme une recommandation venant de vous-même.
- Le matcher ignore les fichiers statiques et les préchargements du routeur. Les préchargements portent votre propre page comme referrer et exécuteraient sinon le proxy pour chaque lien à l'écran.
- `URLSearchParams` donne une valeur de cookie compacte et un parseur sûr. L'action ne lit que les cinq clés qu'elle connaît : un visiteur qui modifie le cookie ne peut donc pas ajouter de champs.

## Validation, notation et passage de relais

Le schéma vit dans son propre module, pour que l'action et toute future route API le partagent. La règle d'adéquation est volontairement simple : au moins onze personnes, un budget de 10 k$ ou plus, et un démarrage dans les deux trimestres.

`lib/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(),
});

export type Lead = z.infer<typeof LeadSchema>;

export function isQualified(lead: Lead) {
  const bigEnough = lead.company_size !== "1_10";
  const funded = lead.budget === "10k_50k" || lead.budget === "50k_plus";
  const soon = lead.timeline === "this_quarter" || lead.timeline === "next_quarter";
  return bigEnough && funded && soon;
}
```

`app/contact-sales/actions.ts`:

```ts
"use server";

import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { isQualified, LeadSchema } from "@/lib/lead";

export interface LeadState {
  errors: Record<string, string>;
  values: Record<string, string>;
}

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export async function submitLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
  const values = Object.fromEntries([...formData].map(([key, value]) => [key, String(value)]));
  const parsed = LeadSchema.safeParse(values);
  if (!parsed.success) {
    const { fieldErrors } = z.flattenError(parsed.error);
    const errors = Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""]));
    return { errors, values };
  }

  const stored = new URLSearchParams((await cookies()).get("lead_source")?.value ?? "");
  const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
  const origin = (await headers()).get("origin");
  const qualified = isQualified(parsed.data);

  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      ...parsed.data,
      ...source,
      fit: qualified ? "qualified" : "nurture",
      _gotcha: values._gotcha ?? "",
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);
  const result = await response?.json().catch(() => null);

  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
    return { errors, values };
  }

  if (!qualified || values._gotcha) redirect("/contact-sales/thanks");
  const { name, email } = parsed.data;
  redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name, email })}`);
}
```

- Zod s'exécute avant que quoi que ce soit ne quitte votre serveur, et `z.flattenError` transforme ses problèmes en un message par champ.
- Le navigateur envoie un en-tête `Origin` avec chaque envoi de server action, et l'action le transmet : un fetch côté serveur n'en envoie aucun, et c'est grâce à lui qu'Ikiform détecte les tests gratuits depuis `localhost`.
- `fit` est calculé côté serveur. Un visiteur ne peut pas se marquer comme qualifié en modifiant le formulaire.
- Les deux appels à `redirect()` sont hors de tout bloc `try`. `redirect()` fonctionne en levant une exception, qu'un `catch` avalerait.
- Un honeypot rempli est quand même envoyé, donc Ikiform l'enregistre comme spam, mais il n'atteint jamais votre agenda.

## Le formulaire, avec et sans JavaScript

`app/contact-sales/lead-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type LeadState, submitLead } from "./actions";

const initial: LeadState = { errors: {}, values: {} };

function FieldError({ message }: { message?: string }) {
  return message ? <p role="alert">{message}</p> : null;
}

export function LeadForm() {
  const [state, action, pending] = useActionState(submitLead, initial);
  const { errors, values } = state;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      <FieldError message={errors.name} />

      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      <FieldError message={errors.email} />

      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      <FieldError message={errors.company} />

      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""} key={values.company_size}>
        <option value="" disabled>Choose one</option>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_1000">201 to 1,000</option>
        <option value="1000_plus">More than 1,000</option>
      </select>
      <FieldError message={errors.company_size} />

      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={values.budget ?? ""} key={values.budget}>
        <option value="" disabled>Choose one</option>
        <option value="under_10k">Under $10k</option>
        <option value="10k_50k">$10k to $50k</option>
        <option value="50k_plus">More than $50k</option>
        <option value="unsure">Not sure yet</option>
      </select>
      <FieldError message={errors.budget} />

      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""} key={values.timeline}>
        <option value="" disabled>Choose one</option>
        <option value="this_quarter">This quarter</option>
        <option value="next_quarter">Next quarter</option>
        <option value="later">Later this year</option>
        <option value="researching">Researching for now</option>
      </select>
      <FieldError message={errors.timeline} />

      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <FieldError message={errors.form} />
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

`app/contact-sales/page.tsx`:

```tsx
import { LeadForm } from "./lead-form";

export default function ContactSales() {
  return (
    <main>
      <h1>Talk to sales</h1>
      <p>Tell us a little about your team. If we look like a fit, you pick a call time on the next screen.</p>
      <LeadForm />
    </main>
  );
}
```

`useActionState` donne au formulaire son indicateur d'attente et les erreurs renvoyées. Comme l'action est une server function, le formulaire envoie aussi avant l'hydratation : Next.js rend le résultat côté serveur et le visiteur voit les mêmes erreurs, simplement avec un rechargement complet de la page.

React réinitialise un formulaire après l'exécution de son action : chaque chemin d'erreur renvoie donc `values`, et chaque input le relit comme `defaultValue`. La `key` sur chaque `select` force son remontage avec le choix renvoyé ; un `select` ne reprend pas de lui-même un `defaultValue` modifié.

### Honeypot et vérification humaine

Le honeypot masqué est transmis tel quel. La vérification humaine d'Ikiform ne s'exécute pas sur un envoi avec clé secrète : si ce formulaire attire des bots, vérifiez un token Turnstile dans l'action avant d'envoyer, comme dans [Turnstile sur un formulaire](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

## Rendu et déploiement

Rien sur la page de contact ne lit les données de la requête, donc `next build` la prérend. Le proxy et l'action s'exécutent à chaque requête. Vérifiez comment votre hébergeur les exécute :

| Hébergeur | À savoir |
| --- | --- |
| Vercel | Le proxy et les server actions fonctionnent sans configuration. Ajoutez `IKIFORM_SECRET_KEY` dans les variables d'environnement du projet pour Production et Preview. |
| Netlify | Next.js passe par l'adaptateur de Netlify sans configuration. Définissez la clé dans les variables d'environnement du site, avec le scope Functions. |
| Cloudflare | Déployez avec l'adaptateur OpenNext pour Cloudflare et stockez la clé avec `wrangler secret put`. Consultez sa documentation sur la prise en charge actuelle du proxy avant de compter sur le cookie. |

## Vérifiez que tout fonctionne

Les envois depuis `next 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 `next dev` et ouvrez `/pricing?utm_source=test&utm_campaign=launch`. Vérifiez le cookie `lead_source` dans les outils de développement du navigateur.
2. Cliquez jusqu'à `/contact-sales`, laissez **Company size** vide et envoyez. L'erreur s'affiche sous le champ et les autres valeurs restent.
3. Envoyez comme un lead qualifié : 51 à 200 personnes, 10 k$ à 50 k$, ce trimestre. Vous arrivez sur Cal.com avec votre nom et votre e-mail remplis.
4. Envoyez comme une équipe de 1 à 10 personnes. Vous arrivez sur `/contact-sales/thanks`.
5. Ouvrez **Submissions** dans le tableau de bord. Les deux leads y sont, avec `utm_source`, `landing_page` 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 |
| --- | --- |
| Les champs UTM arrivent vides | Le cookie a été posé avant l'ajout des tags, ou le fichier proxy n'est pas à la racine du projet (ou dans `src/` si vous en utilisez un). Effacez le cookie et rechargez avec les tags. |
| La redirection n'a jamais lieu et le formulaire n'affiche rien | `redirect()` a été appelé dans `try`. Déplacez-le après le bloc, comme dans l'exemple. |
| `422` avec `Unknown field` pour `fit` ou `referrer` | La définition des champs du formulaire ne le liste pas. Ajoutez chaque id du tableau comme champ facultatif. |
| `402 test_limit_reached` | L'espace de travail n'a pas d'offre et a utilisé ses 50 tests gratuits depuis `localhost` ce mois-ci. Choisissez une offre, ou utilisez **Send test** dans le tableau de bord. |
| Les selects reviennent à **Choose one** après une erreur | La prop `key` manque, donc React garde le `select` réinitialisé. Donnez à chacun une key basée sur sa valeur renvoyée. |

## Questions

### Une server action Next.js peut-elle rediriger vers une URL externe ?

Oui. redirect() de next/navigation accepte une URL absolue. Dans une server action, elle navigue côté client quand JavaScript est chargé et répond par un 303 pour un formulaire envoyé avant l'hydratation. Appelez-la hors de tout bloc try, car elle fonctionne en levant une exception.

### Pourquoi capturer les paramètres UTM dans proxy.ts plutôt que sur la page de contact ?

Les visiteurs se convertissent rarement sur la page où ils arrivent. Le proxy s'exécute à la première requête, stocke les tags et le referrer dans un cookie, et la server action le lit plus tard. La page de contact reste statique car elle ne lit jamais l'URL.

### Faut-il une base de données pour qualifier des leads dans Next.js ?

Non. La server action note le lead, le transmet à Ikiform avec le résultat dans un champ fit, et redirige. Ikiform stocke le lead, écrit à votre équipe et peut envoyer un webhook à votre CRM.

### 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 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 leads Astro](https://ikiform.com/fr/guides/lead-capture/astro.md): Un formulaire de leads Astro construit sur les Actions : validation Zod, UTM du premier contact sur un site statique, une page de contact à la demande et une redirection vers votre outil de réservation.
- [Formulaire de leads React Router](https://ikiform.com/fr/guides/lead-capture/react-router.md): Un formulaire de leads React Router en mode framework : un middleware racine avec un cookie de premier contact signé, une action de route qui valide et une redirection vers votre outil de réservation.
- [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.
