Formulario de suscripción a newsletter8 min de lectura
Crea un formulario de suscripción a newsletter en TanStack Start
Un formulario de newsletter en TanStack Start: una server function para suscribirse sin salir de la página, una server route como alternativa sin JavaScript y las suscripciones reales enviadas a beehiiv.
Escribe la lógica de suscripción una vez en un módulo de servidor y llámala desde dos sitios: una función POST de createServerFn a la que llama el formulario en la página, y una server route a la que envía el mismo formulario cuando JavaScript no ha cargado. Ambas comprueban la casilla de consentimiento y reenvían la suscripción a tu backend de formularios con una clave secreta. Una segunda server route recibe el webhook firmado del backend y crea la suscripción en beehiiv.
La pestaña de TanStack Start de la guía principal es una server function y un componente. Eso cubre a los lectores cuyo JavaScript ha cargado. Esta página añade lo que necesita un sitio que compartes mucho: un camino para el lector que envía antes de la hidratación, una página de resultados para ese camino y una ruta de webhook que pasa las suscripciones reales a beehiiv. La configuración del producto está en la guía de servidor de la documentación.
Qué cambia con TanStack Start
A una server function se la llama mediante una petición RPC que hace el cliente, así que un formulario que solo conoce onSubmit no hace nada hasta que se ejecuta el bundle. La documentación de TanStack menciona una propiedad .url para enviar un formulario a una server function sin JavaScript, pero no documenta cómo se renderiza esa respuesta. Una server route es más sencilla: recibe un envío de formulario normal y devuelve una redirección normal. Así que el formulario tiene un action real además de su onSubmit:
- El lector envía el formulario. Que JavaScript haya cargado o no decide el camino.
- Hidratado: onSubmit detiene el envío y llama a la server function con los datos del formulario.
- La server function ejecuta forwardSignup, que comprueba el consentimiento y envía a Ikiform, y devuelve el resultado al componente.
- Sin hidratar: el navegador envía a la server route /api/newsletter, que ejecuta el mismo forwardSignup.
- La ruta responde 303 con un estado o un código de error, y la página /newsletter muestra el mensaje correspondiente.
Configuración del proyecto
src/
server/newsletter.ts
components/NewsletterForm.tsx
routes/
__root.tsx
newsletter.tsx
api/newsletter.ts
api/ikiform.ts
.envEl código de servidor lee los secretos de process.env. Nunca les pongas el prefijo VITE_: Vite mete las variables VITE_ en el bundle del cliente. Crea la clave de Ikiform en API keys con el scope submissions:write y pega la definición de campos de la guía principal en la pestaña Fields del formulario.
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-000000000000Constrúyelo
El módulo de suscripción
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." };
}readSignupconvierte elFormDataen un objeto plano. Lo usan los dos puntos de entrada, así que cada nombre de campo vive en un solo sitio.sourcedebe ser una de tres ubicaciones, así que un envío manipulado no puede inventarse otras.- La línea de
Originreenvía el origen del navegador, porque Ikiform reconoce por esa cabecera las pruebas gratuitas desdelocalhosty elfetchde un servidor no envía ninguna. - Cada fallo devuelve un
Outcomeen lugar de lanzar una excepción, así que tanto el componente como la ruta pueden mostrarlo.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.La server function y el formulario
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>
);
}.validatorrecibe lo que haya enviado el cliente. Comprobar ahí que esFormDatahace que una llamada JSON manipulada falle antes de que se ejecute el handler.getRequestHeaderviene de@tanstack/react-start/servery lee la petición que llevó la llamada RPC, que el navegador envió con el origen de tu página.- El
FormDatase construye antes del primerawait, mientrasevent.currentTargetaún apunta al formulario. useServerFnpermite que la llamada siga las redirecciones y los errores a través del router, lo que importa si más adelante lanzasredirect()desde el handler.
La ruta y la página sin 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 ruta redirige con 303, así que recargar la página de resultado no vuelve a enviar el formulario. Pasa un código de error, nunca el mensaje de Ikiform, y validateSearch descarta todo lo que no sea uno de los tres códigos, así que nadie puede poner su propio texto en tu página mediante un enlace.
Reenvía los suscriptores a beehiiv
Esta server route recibe el webhook firmado de Ikiform y crea la suscripción en beehiiv con double_opt_override: "on", para que beehiiv envíe la confirmación. Lee el cuerpo con request.text() antes que nada, porque la firma cubre los bytes exactos. La guía de beehiiv añade el nombre, el referrer y las cabeceras de límite de frecuencia 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 });
},
},
},
});Añade https://your-site.com/api/ikiform en Webhooks, elige el formulario de la newsletter y copia el secreto whsec_ en IKIFORM_WEBHOOK_SECRET.
Honeypot y verificación humana
El input _gotcha viaja con la suscripción, e Ikiform guarda como spam uno relleno con una respuesta de éxito y sin webhook, así que beehiiv nunca lo ve. La clave secreta se salta la comprobación de Turnstile de Ikiform. Si los bots superan el honeypot y la casilla obligatoria, verifica un token de Turnstile en forwardSignup, como muestra la guía de Turnstile, para que lo tengan los dos puntos de entrada.
Renderizado
Start renderiza las rutas en el servidor por defecto, así que el HTML del formulario, action incluido, está en la primera respuesta. Si prerenderizas páginas como archivos estáticos, el formulario sigue funcionando en ellas: la server function y las dos server routes se ejecutan bajo demanda en el hosting.
Despliegue
| Host | Qué hacer |
|---|---|
| Vercel | Añade Nitro: instala nitro y pon nitro() de nitro/vite en los plugins de Vite. Define las cuatro variables en Environment Variables. |
| Netlify | Añade @netlify/vite-plugin-tanstack-start y pon netlify() en los plugins de Vite. Define las variables en los ajustes de entorno del sitio. |
| Cloudflare | Añade @cloudflare/vite-plugin el primero en los plugins, con un wrangler.jsonc que active nodejs_compat. Con ese flag y una fecha de compatibilidad reciente, Workers expone las variables y los secretos en process.env. Usa un dominio propio para la URL del webhook; Ikiform rechaza workers.dev. |
Pruébalo gratis en localhost
Un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost, y el Origin reenviado hace que cada suscripción desde el servidor de desarrollo cuente como uno de ellos. Prueba los dos caminos: una vez normalmente y otra con JavaScript desactivado en las herramientas de desarrollo. Ikiform no entrega webhooks a localhost, así que prueba /api/ikiform mediante un túnel con URL HTTPS.
Comprueba que funciona
- Arranca el servidor de desarrollo y suscríbete desde el footer. El formulario se sustituye por la línea que pide revisar la bandeja de entrada y la URL no cambia.
- Desactiva JavaScript y vuelve a suscribirte. Llegas a
/newsletter?status=subscribed. - Con JavaScript aún desactivado, envía con
requiredquitado y la casilla sin marcar. Llegas a/newsletter?error=consentcon el mensaje de consentimiento. - Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
- Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra
succeededcon la respuesta de tu servidor. - Abre beehiiv. La suscripción está ahí, pendiente hasta que confirmes, y el email de confirmación está en tu bandeja de entrada.
Errores comunes
| Problema | Qué hacer |
|---|---|
La clave secreta es undefined en producción | El hosting no recibió la variable, o se leyó en código que se ejecuta en el navegador. Lee los secretos solo dentro de server functions y server routes. |
| Una página de error JSON tras enviar sin JavaScript | El action del formulario apunta a la server function en lugar de a la server route. Envía a /api/newsletter. |
402 plan_required en las pruebas locales | El envío llegó a Ikiform sin un origen localhost. Comprueba que forwardSignup recibe el origen desde los dos sitios que lo llaman. |
Las entregas del webhook fallan con 400 | El secreto no coincide con el del endpoint, o algo analizó el cuerpo antes de request.text(). Lee primero el texto sin procesar. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos y márcalo como no spam para enviar su email y sus webhooks. |
Preguntas
La documentación de TanStack menciona una propiedad .url para enviar un formulario a una server function, pero una server route es la alternativa más sencilla: recibe un envío de formulario normal y redirige. Esta página usa las dos, con un módulo compartido detrás.
En variables de entorno solo de servidor, leídas con process.env dentro de server functions y server routes. Las variables con el prefijo VITE_ acaban en el bundle del navegador.
Ikiform responde al spam con el mismo éxito que a una suscripción real. Su webhook solo se envía para las suscripciones que no son spam, así que crear la suscripción desde el webhook mantiene a los bots fuera de tu lista.
No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.