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

Publicado el 2026-10-25 · https://ikiform.com/es/guides/newsletter-signup/tanstack-start

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](https://ikiform.com/es/guides/newsletter-signup.md#form) 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](https://ikiform.com/es/docs/guides/server.md).

## 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`:

Diagrama: Cómo llega un formulario de TanStack Start al mismo código de suscripción con o sin JavaScript

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`:

```text
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](https://ikiform.com/es/guides/newsletter-signup.md#fields) en la pestaña **Fields** del formulario.

`.env`:

```bash
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`:

```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](https://ikiform.com/es/docs/rate-limits.md) 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`:

```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`:

```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`:

```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](https://ikiform.com/es/guides/newsletter-signup/beehiiv.md) añade el nombre, el referrer y las cabeceras de límite de frecuencia de beehiiv.

`src/routes/api/ikiform.ts`:

```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](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md), 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

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

| 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

### ¿Funcionan sin JavaScript las server functions de TanStack Start?

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.

### ¿Dónde van los secretos en TanStack Start?

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.

### ¿Por qué no añadir el suscriptor a beehiiv dentro de la server function?

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.

### ¿Ikiform es gratis?

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.

## Más guías

- [Formulario de suscripción a newsletter](https://ikiform.com/es/guides/newsletter-signup.md): Crea un formulario de suscripción a tu newsletter con tu propio markup, registra el consentimiento, bloquea los registros de bots y pasa cada suscriptor a Kit, MailerLite, beehiiv o cualquier herramienta.
- [beehiiv](https://ikiform.com/es/guides/newsletter-signup/beehiiv.md): Conecta tu propio formulario de suscripción con beehiiv: un relay crea cada suscripción con el doble opt-in activado, la ubicación como fuente UTM y la página como referrer.
- [Doble opt-in](https://ikiform.com/es/guides/newsletter-signup/double-opt-in.md): Ikiform no envía emails de confirmación, así que el doble opt-in se hace en tu herramienta de email: un relay añade cada suscripción como no confirmada y la herramienta envía el enlace.
- [Formulario de newsletter en React Router](https://ikiform.com/es/guides/newsletter-signup/react-router.md): Un formulario de newsletter en el footer con React Router: useFetcher envía a una ruta /newsletter que también funciona sin JavaScript, y una resource route alimenta MailerLite.
- [Sitio estático](https://ikiform.com/es/guides/newsletter-signup/static-site.md): Añade un formulario de suscripción a tu newsletter en un sitio estático con Hugo, Jekyll, Eleventy o Astro, deja la API key fuera de la página y confirma en la herramienta.
- [Kit (ConvertKit)](https://ikiform.com/es/guides/newsletter-signup/kit.md): Conecta tu propio formulario de newsletter con Kit mediante un relay que crea el suscriptor, lo añade a un formulario de Kit para el incentive email y etiqueta la ubicación.
