Formulaire d'inscription à la newsletter

Formulaire d'inscription à la newsletter8 min de lecture

Créer un formulaire d'inscription à la newsletter avec TanStack Start

Un formulaire de newsletter TanStack Start : une server function pour les inscriptions dans la page, une server route en repli sans JavaScript, et les vraies inscriptions envoyées à beehiiv.

Preet SutharFondateur, Ikiform

Écrivez la logique d'inscription une fois dans un module serveur, puis appelez-la depuis deux endroits : une fonction POST createServerFn que le formulaire appelle dans la page, et une server route vers laquelle le même formulaire envoie quand JavaScript n'est pas chargé. Les deux vérifient la case de consentement et transmettent l'inscription à votre backend de formulaires avec une clé secrète. Une seconde server route reçoit le webhook signé du backend et crée l'abonnement dans beehiiv.

L'onglet TanStack Start du guide principal se compose d'une server function et d'un composant. Cela couvre les lecteurs dont le JavaScript est chargé. Cette page ajoute ce dont a besoin un site largement partagé : un chemin pour le lecteur qui envoie avant l'hydratation, une page de résultat pour ce chemin, et une route de webhook qui transmet les vraies inscriptions à beehiiv. La configuration du produit est dans le guide côté serveur de la documentation.

Ce que change TanStack Start

Une server function est appelée par une requête RPC que fait le client : un formulaire qui ne connaît que onSubmit ne fait donc rien tant que le bundle ne s'exécute pas. La documentation de TanStack mentionne une propriété .url pour envoyer un formulaire vers une server function sans JavaScript, mais ne documente pas comment cette réponse s'affiche. Une server route est plus simple : elle accepte un envoi de formulaire normal et renvoie une redirection normale. Le formulaire reçoit donc une vraie action en plus de son onSubmit :

Comment un formulaire TanStack Start atteint le même code d'inscription avec ou sans JavaScriptNewsletterFormaction="/api/newsletter"hydraté ?onSubmitbranchéserverfunctionRPC,mêmepageforwardSignup()consentement,puisIkiformrésultataffichésurplace/api/newsletterrouteserveur,POST duformulaire303vers/newsletter?statusou ?errorenvoiouidonnéesRésultatpas encoreComment un formulaire TanStack Start atteint le même code d'inscription avec ou sans JavaScriptNewsletterFormaction="/api/newsletter"hydraté ?onSubmitbranchéserver functionRPC, même pageforwardSignup()consentement, puisIkiformrésultataffiché sur place/api/newsletterrouteserveur,POST duformulaire303 vers/newsletter?statusou ?errorenvoiouidonnéesRésultatpas encore
  1. Le lecteur envoie le formulaire. Le chargement ou non de JavaScript décide du chemin.
  2. Après hydratation : onSubmit bloque l'envoi et appelle la server function avec les données du formulaire.
  3. La server function exécute forwardSignup, qui vérifie le consentement et envoie à Ikiform, puis renvoie le résultat au composant.
  4. Sans hydratation : le navigateur envoie à la route serveur /api/newsletter, qui exécute le même forwardSignup.
  5. La route répond 303 avec un statut ou un code d'erreur, et la page /newsletter affiche le message correspondant.
forwardSignup est le seul code qui parle à Ikiform, donc les deux chemins valident et signalent les erreurs de la même façon.

Mise en place du projet

Arborescence du projet
src/
  server/newsletter.ts
  components/NewsletterForm.tsx
  routes/
    __root.tsx
    newsletter.tsx
    api/newsletter.ts
    api/ikiform.ts
.env

Le code serveur lit les secrets depuis process.env. Ne leur donnez jamais le préfixe VITE_ : Vite place les variables VITE_ dans le bundle client. Créez la clé Ikiform sous API keys avec le scope submissions:write et collez la définition des champs du guide principal dans l'onglet Fields du formulaire.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000

La construire

Le module d'inscription

src/server/newsletter.ts
export interface Signup {
  email: string;
  first_name?: string;
  consent: boolean;
  source: string;
  _gotcha: string;
}

export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };

const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);

export function readSignup(form: FormData): Signup {
  const source = String(form.get("source") ?? "");
  return {
    email: String(form.get("email") ?? "").trim(),
    first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
    consent: form.get("consent") === "true",
    source: PLACEMENTS.has(source) ? source : "footer",
    _gotcha: String(form.get("_gotcha") ?? ""),
  };
}

export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
  if (!signup.consent) {
    return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
  }
  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({ ...signup, consent: true }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true };
  const email = result?.error?.fields?.email?.[0];
  if (email) return { ok: false, field: "email", message: email };
  return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}
  • readSignup transforme FormData en objet simple. Les deux points d'entrée l'utilisent, donc chaque nom de champ n'est défini qu'à un seul endroit.
  • source doit être l'un des trois emplacements, pour qu'un envoi forgé ne puisse pas en inventer d'autres.
  • La ligne Origin transmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuis localhost, et le fetch d'un serveur n'en envoie aucun.
  • Chaque échec renvoie un Outcome au lieu de lever une exception, pour que le composant et la route puissent tous deux l'afficher.
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.

La server function et le formulaire

src/components/NewsletterForm.tsx
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";

const subscribe = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return readSignup(data);
  })
  .handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));

export function NewsletterForm({ source = "footer" }: { source?: string }) {
  const send = useServerFn(subscribe);
  const [outcome, setOutcome] = useState<Outcome | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setPending(true);
    try {
      setOutcome(await send({ data }));
    } catch {
      setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = outcome?.ok === false ? outcome : null;

  return (
    <form method="post" action="/api/newsletter" onSubmit={onSubmit}>
      <label>
        Email
        <input name="email" type="email" required autoComplete="email" aria-invalid={error?.field === "email"} />
      </label>
      <label>
        First name (optional)
        <input name="first_name" maxLength={80} autoComplete="given-name" />
      </label>
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={error?.field === "consent"} />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      <input type="hidden" name="source" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
    </form>
  );
}
  • .validator reçoit tout ce que le client a envoyé. En y vérifiant la présence de FormData, un appel JSON forgé échoue avant l'exécution du handler.
  • getRequestHeader vient de @tanstack/react-start/server et lit la requête qui a porté l'appel RPC, que le navigateur a envoyée avec l'origine de votre page.
  • Le FormData est construit avant le premier await, tant que event.currentTarget pointe encore vers le formulaire.
  • useServerFn permet à l'appel de suivre les redirections et les erreurs à travers le routeur, ce qui compte si vous levez plus tard redirect() depuis le handler.

La route et la page sans JavaScript

src/routes/api/newsletter.ts
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";

export const Route = createFileRoute("/api/newsletter")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const signup = readSignup(await request.formData());
        const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
        const target = new URL("/newsletter", request.url);
        target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
        return Response.redirect(target, 303);
      },
    },
  },
});
src/routes/newsletter.tsx
import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";

const MESSAGES = {
  consent: "Tick the box so we know you want the emails.",
  email: "Check the email address and try again.",
  failed: "We couldn't reach the list. Try again.",
};

export const Route = createFileRoute("/newsletter")({
  validateSearch: (search: Record<string, unknown>) => ({
    status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
    error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
  }),
  component: NewsletterPage,
});

function NewsletterPage() {
  const { status, error } = Route.useSearch();
  if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  return (
    <main>
      <h1>The newsletter</h1>
      {error ? <p role="alert">{MESSAGES[error]}</p> : null}
      <NewsletterForm source="newsletter-page" />
    </main>
  );
}

La route redirige avec 303, donc recharger la page de résultat ne renvoie pas le formulaire. Elle transmet un code d'erreur, jamais le message d'Ikiform, et validateSearch écarte tout ce qui sort des trois codes, pour que personne ne puisse afficher son propre texte sur votre page via un lien.

Transférer les abonnés vers beehiiv

Cette route serveur reçoit le webhook signé d'Ikiform et crée l'abonnement beehiiv avec double_opt_override: "on", pour que beehiiv envoie l'e-mail de confirmation. Elle lit le corps avec request.text() avant toute autre chose, car la signature couvre les octets exacts. Le guide beehiiv ajoute le prénom, le référent et les en-têtes de limite de débit de beehiiv.

src/routes/api/ikiform.ts
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const Route = createFileRoute("/api/ikiform")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        let event;
        try {
          event = await verifyWebhook({
            payload: await request.text(),
            headers: request.headers,
            secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
          });
        } catch (error) {
          if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
          throw error;
        }
        if (event.type !== "submission.created") return new Response(null, { status: 204 });

        const { email, consent, source } = event.data.fields;
        if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

        const answer = await fetch(
          `https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
          {
            method: "POST",
            headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
            body: JSON.stringify({
              email,
              double_opt_override: "on",
              reactivate_existing: false,
              utm_source: typeof source === "string" ? source : "website",
              utm_medium: "signup_form",
            }),
          },
        );
        if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
        if (!answer.ok) return new Response(`beehiiv answered ${answer.status}`, { status: 502 });
        return new Response(null, { status: 204 });
      },
    },
  },
});

Ajoutez https://your-site.com/api/ikiform sous Webhooks, choisissez le formulaire de newsletter et copiez le secret whsec_ dans IKIFORM_WEBHOOK_SECRET.

Honeypot et vérification humaine

L'input _gotcha accompagne l'inscription, et Ikiform enregistre un champ rempli comme spam avec une réponse de succès et sans webhook, donc beehiiv ne le voit jamais. La clé secrète contourne la vérification Turnstile d'Ikiform. Si des bots passent le honeypot et la case obligatoire, vérifiez un jeton Turnstile dans forwardSignup, comme le montre le guide Turnstile, pour que les deux points d'entrée en profitent.

Rendu

Start rend les routes sur le serveur par défaut, donc le HTML du formulaire, action compris, figure dans la première réponse. Si vous prérendez des pages en fichiers statiques, le formulaire y fonctionne quand même : la server function et les deux routes serveur s'exécutent à la demande chez l'hébergeur.

Déployer

HébergeurQue faire
VercelAjoutez Nitro : installez nitro et placez nitro() depuis nitro/vite dans les plugins Vite. Définissez les quatre variables sous Environment Variables.
NetlifyAjoutez @netlify/vite-plugin-tanstack-start et placez netlify() dans les plugins Vite. Définissez les variables dans les paramètres d'environnement du site.
CloudflareAjoutez @cloudflare/vite-plugin en premier dans les plugins, avec un wrangler.jsonc qui active nodejs_compat. Avec ce flag et une date de compatibilité récente, Workers expose les variables et les secrets sur process.env. Utilisez un domaine personnalisé pour l'URL du webhook ; Ikiform refuse workers.dev.

Tester gratuitement sur localhost

Un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost, et l'en-tête Origin transmis fait de chaque inscription depuis le serveur de développement l'une d'elles. Testez les deux chemins : une fois normalement, et une fois avec JavaScript désactivé dans les outils de développement. Ikiform ne livre pas de webhooks à localhost, donc testez /api/ikiform via un tunnel avec une URL HTTPS.

Vérifiez que tout fonctionne

  1. Lancez le serveur de développement et abonnez-vous depuis le pied de page. Le formulaire est remplacé par la ligne vous invitant à consulter votre boîte de réception et l'URL ne change pas.
  2. Désactivez JavaScript et abonnez-vous à nouveau. Vous arrivez sur /newsletter?status=subscribed.
  3. Avec JavaScript toujours désactivé, envoyez sans required et avec la case décochée. Vous arrivez sur /newsletter?error=consent avec le message de consentement.
  4. Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
  5. Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche succeeded avec la réponse de votre serveur.
  6. Ouvrez beehiiv. L'abonnement y figure, en attente jusqu'à votre confirmation, et l'e-mail de confirmation est dans votre boîte de réception.

Pièges

ProblèmeQue faire
La clé secrète vaut undefined en productionL'hébergeur n'a pas reçu la variable, ou elle a été lue dans du code qui s'exécute dans le navigateur. Ne lisez les secrets que dans les server functions et les routes serveur.
Une page d'erreur JSON après un envoi sans JavaScriptL'action du formulaire pointe vers la server function au lieu de la route serveur. Envoyez vers /api/newsletter.
402 plan_required sur les tests locauxL'envoi a atteint Ikiform sans origine localhost. Vérifiez que forwardSignup reçoit l'origine depuis les deux appelants.
Les livraisons de webhook échouent avec 400Le secret ne correspond pas à celui de l'endpoint, ou quelque chose a analysé le corps avant request.text(). Lisez d'abord le texte brut.
La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivéElle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks.

Questions

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