Formulaire de capture de leads

Formulaire de capture de leads9 min de lecture

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.

Preet SutharFondateur, Ikiform

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

Comment un lead Next.js voyage du premier clic sur une annonce à un appel réservépremière visiteenvoiannonce ou lien?utm_source=...proxy.tspose le cookielead_sourcepage statiqueservi depuis le cacheserver actionZod, lit le cookieIkiformlead avec UTM etadéquationredirect()Cal.com, pré-rempliétat de l'actionerreurs et valeurssaisiesGETnext()JSONok422 ou erreur ZodComment un lead Next.js voyage du premier clic sur une annonce à un appel réservépremière visiteenvoiannonce ou lien?utm_source=...proxy.tspose le cookielead_sourcepage statiqueservi depuis lecacheserver actionZod, lit le cookieIkiformlead avec UTM etadéquationredirect()Cal.com, pré-rempliétat del'actionerreurs etvaleurssaisiesGETnext()JSONok422 ou erreur Zod
  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 détaille la clé et la configuration du formulaire.

fichiers
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
.env.local
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 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.

Attribution au premier contact dans proxy.ts

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

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
VercelLe proxy et les server actions fonctionnent sans configuration. Ajoutez IKIFORM_SECRET_KEY dans les variables d'environnement du projet pour Production et Preview.
NetlifyNext.js passe par l'adaptateur de Netlify sans configuration. Définissez la clé dans les variables d'environnement du site, avec le scope Functions.
CloudflareDé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.

  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èmeQue faire
Les champs UTM arrivent videsLe 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 rienredirect() a été appelé dans try. Déplacez-le après le bloc, comme dans l'exemple.
422 avec Unknown field pour fit ou referrerLa définition des champs du formulaire ne le liste pas. Ajoutez chaque id du tableau comme champ facultatif.
402 test_limit_reachedL'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 erreurLa prop key manque, donc React garde le select réinitialisé. Donnez à chacun une key basée sur sa valeur renvoyée.

Questions

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