Formulario de suscripción a newsletter

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.

Preet SutharFundador, Ikiform

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:

Cómo llega un formulario de TanStack Start al mismo código de suscripción con o sin JavaScriptNewsletterFormaction="/api/newsletter"¿hidratado?onSubmitconectadoserverfunctionRPC,mismapáginaforwardSignup()consentimiento,luegoIkiformresultadosemuestraen susitio/api/newsletterserverroute,POST deformulario303 a/newsletter?statuso ?errorenvíosídataOutcomeaún noCómo llega un formulario de TanStack Start al mismo código de suscripción con o sin JavaScriptNewsletterFormaction="/api/newsletter"¿hidratado?onSubmitconectadoserver functionRPC, misma páginaforwardSignup()consentimiento, luegoIkiformresultadose muestra en su sitio/api/newsletterserver route,POST deformulario303 a/newsletter?status o?errorenvíosídataOutcomeaún no
  1. El lector envía el formulario. Que JavaScript haya cargado o no decide el camino.
  2. Hidratado: onSubmit detiene el envío y llama a la server function con los datos del formulario.
  3. La server function ejecuta forwardSignup, que comprueba el consentimiento y envía a Ikiform, y devuelve el resultado al componente.
  4. Sin hidratar: el navegador envía a la server route /api/newsletter, que ejecuta el mismo forwardSignup.
  5. La ruta responde 303 con un estado o un código de error, y la página /newsletter muestra el mensaje correspondiente.
forwardSignup es el único código que habla con Ikiform, así que ambos caminos validan e informan de los errores de la misma forma.

Configuración del proyecto

Árbol del proyecto
src/
  server/newsletter.ts
  components/NewsletterForm.tsx
  routes/
    __root.tsx
    newsletter.tsx
    api/newsletter.ts
    api/ikiform.ts
.env

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

.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

Constrúyelo

El módulo de suscripción

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 convierte el FormData en un objeto plano. Lo usan los dos puntos de entrada, así que cada nombre de campo vive en un solo sitio.
  • source debe ser una de tres ubicaciones, así que un envío manipulado no puede inventarse otras.
  • La línea de Origin reenvía el origen del navegador, porque Ikiform reconoce por esa cabecera las pruebas gratuitas desde localhost y el fetch de un servidor no envía ninguna.
  • Cada fallo devuelve un Outcome en lugar de lanzar una excepción, así que tanto el componente como la ruta pueden mostrarlo.
Las versiones de servidor envían con una clave secreta, así que Ikiform se salta Allowed domains (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el límite de frecuencia Relaxed o Custom para un formulario que envías a través de un servidor, y guarda IKIFORM_SECRET_KEY en variables de entorno solo de servidor.

La server function y el formulario

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 recibe lo que haya enviado el cliente. Comprobar ahí que es FormData hace que una llamada JSON manipulada falle antes de que se ejecute el handler.
  • getRequestHeader viene de @tanstack/react-start/server y lee la petición que llevó la llamada RPC, que el navegador envió con el origen de tu página.
  • El FormData se construye antes del primer await, mientras event.currentTarget aún apunta al formulario.
  • useServerFn permite que la llamada siga las redirecciones y los errores a través del router, lo que importa si más adelante lanzas redirect() desde el handler.

La ruta y la página sin 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 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.

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 });
      },
    },
  },
});

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

HostQué hacer
VercelAñade Nitro: instala nitro y pon nitro() de nitro/vite en los plugins de Vite. Define las cuatro variables en Environment Variables.
NetlifyAñade @netlify/vite-plugin-tanstack-start y pon netlify() en los plugins de Vite. Define las variables en los ajustes de entorno del sitio.
CloudflareAñ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

  1. 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.
  2. Desactiva JavaScript y vuelve a suscribirte. Llegas a /newsletter?status=subscribed.
  3. Con JavaScript aún desactivado, envía con required quitado y la casilla sin marcar. Llegas a /newsletter?error=consent con el mensaje de consentimiento.
  4. Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
  5. Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra succeeded con la respuesta de tu servidor.
  6. 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

ProblemaQué hacer
La clave secreta es undefined en producciónEl 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 JavaScriptEl 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 localesEl 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 400El 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 webhookSe 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.