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.
É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 :
- Le lecteur envoie le formulaire. Le chargement ou non de JavaScript décide du chemin.
- Après hydratation : onSubmit bloque l'envoi et appelle la server function avec les données du formulaire.
- La server function exécute forwardSignup, qui vérifie le consentement et envoie à Ikiform, puis renvoie le résultat au composant.
- Sans hydratation : le navigateur envoie à la route serveur /api/newsletter, qui exécute le même forwardSignup.
- La route répond 303 avec un statut ou un code d'erreur, et la page /newsletter affiche le message correspondant.
Mise en place du projet
src/
server/newsletter.ts
components/NewsletterForm.tsx
routes/
__root.tsx
newsletter.tsx
api/newsletter.ts
api/ikiform.ts
.envLe 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.
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-000000000000La construire
Le module d'inscription
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." };
}readSignuptransformeFormDataen objet simple. Les deux points d'entrée l'utilisent, donc chaque nom de champ n'est défini qu'à un seul endroit.sourcedoit être l'un des trois emplacements, pour qu'un envoi forgé ne puisse pas en inventer d'autres.- La ligne
Origintransmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuislocalhost, et lefetchd'un serveur n'en envoie aucun. - Chaque échec renvoie un
Outcomeau lieu de lever une exception, pour que le composant et la route puissent tous deux l'afficher.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.La server function et le formulaire
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>
);
}.validatorreçoit tout ce que le client a envoyé. En y vérifiant la présence deFormData, un appel JSON forgé échoue avant l'exécution du handler.getRequestHeadervient de@tanstack/react-start/serveret lit la requête qui a porté l'appel RPC, que le navigateur a envoyée avec l'origine de votre page.- Le
FormDataest construit avant le premierawait, tant queevent.currentTargetpointe encore vers le formulaire. useServerFnpermet à l'appel de suivre les redirections et les erreurs à travers le routeur, ce qui compte si vous levez plus tardredirect()depuis le handler.
La route et la page sans JavaScript
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);
},
},
},
});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.
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ébergeur | Que faire |
|---|---|
| Vercel | Ajoutez Nitro : installez nitro et placez nitro() depuis nitro/vite dans les plugins Vite. Définissez les quatre variables sous Environment Variables. |
| Netlify | Ajoutez @netlify/vite-plugin-tanstack-start et placez netlify() dans les plugins Vite. Définissez les variables dans les paramètres d'environnement du site. |
| Cloudflare | Ajoutez @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
- 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.
- Désactivez JavaScript et abonnez-vous à nouveau. Vous arrivez sur
/newsletter?status=subscribed. - Avec JavaScript toujours désactivé, envoyez sans
requiredet avec la case décochée. Vous arrivez sur/newsletter?error=consentavec le message de consentement. - Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche
succeededavec la réponse de votre serveur. - 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ème | Que faire |
|---|---|
La clé secrète vaut undefined en production | L'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 JavaScript | L'action du formulaire pointe vers la server function au lieu de la route serveur. Envoyez vers /api/newsletter. |
402 plan_required sur les tests locaux | L'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 400 | Le 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
La documentation de TanStack mentionne une propriété .url pour envoyer un formulaire à une server function, mais une route serveur est la solution de repli la plus simple : elle accepte un POST de formulaire classique et redirige. Cette page utilise les deux, avec un seul module partagé derrière.
Dans des variables d'environnement réservées au serveur, lues avec process.env dans les server functions et les routes serveur. Les variables avec le préfixe VITE_ finissent dans le bundle du navigateur.
Ikiform répond au spam avec le même succès qu'à une vraie inscription. Son webhook n'est envoyé que pour les inscriptions qui ne sont pas du spam, donc créer l'abonnement depuis le webhook tient les bots à l'écart de votre liste.
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.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.