Formulario de suscripción a newsletter

Formulario de suscripción a newsletter10 min de lectura

Crea un formulario de suscripción a newsletter en Next.js

Un formulario de newsletter en el footer de Next.js: una server action con comprobación de consentimiento y Turnstile que envía a Ikiform, más un route handler que añade a los lectores a Kit.

Preet SutharFundador, Ikiform

Pon un formulario de cliente en el footer del layout raíz y apúntalo a una server action. La action comprueba la casilla de consentimiento y un token de Cloudflare Turnstile, y después reenvía la suscripción a tu backend de formularios con una clave secreta. Un route handler aparte recibe el webhook firmado del backend y añade al lector a tu herramienta de email, así que el spam nunca llega a tu lista.

La pestaña de Next.js de la guía principal tiene una server action y un componente de formulario. Esta página construye la versión completa para un sitio real: un formulario en el footer de cada página, una verificación humana que sigue funcionando cuando la clave secreta se salta la de Ikiform, y un route handler que pasa a Kit las suscripciones que se confirman como buenas. La configuración del producto y las claves están en la guía de Next.js de la documentación.

Qué cambia un formulario en el footer en Next.js

Un formulario en el footer vive en el layout raíz, así que se renderiza en todas las páginas, incluidas las que Next.js genera una vez al desplegar. De ahí se siguen tres cosas:

AspectoQué pasaQué hace esta versión
Páginas estáticasEl formulario no debe leer cookies ni cabeceras al renderizar, o todas las páginas se vuelven dinámicasEl formulario no lee nada al renderizar. Solo la action toca la petición
La verificación humanaUn envío con clave secreta se salta la comprobación de Turnstile de IkiformHace su propia comprobación de Turnstile dentro de la action
Quién puede llamarlaUna server action es un endpoint POST público, accesible sin tu formularioValida el consentimiento y el token antes de enviar nada
Cómo llega una suscripción del footer de Next.js a Ikiform y después a Kitmientras el lector esperasegundos despuésformulariodelfooterlayoutraíz,todaslaspáginasserveractionconsentimiento,luegoTurnstileIkiformclavesecreta,guardalasuscripciónestadode laactionrevisatubandejaestadodeerrorno seenvíanada,seconservaelemailIkiformsolo sino esspam/api/ikiformverificalafirmaKitenvíalaconfirmaciónFormDataJSON + Originresultadofalla la comprobaciónwebhook firmadov4 APICómo llega una suscripción del footer de Next.js a Ikiform y después a Kitmientras el lector esperasegundos despuésformulario delfooterlayout raíz, todaslas páginasserveractionconsentimiento,luegoTurnstileIkiformclave secreta,guarda lasuscripciónestado de laactionrevisa tu bandejaestadode errorno se envíanada, seconserva elemailIkiformsolo si no es spam/api/ikiformverifica la firmaKitenvía laconfirmaciónFormDataJSON + Originresultadowebhook firmadov4 APIfalla la comprobación
  1. El lector rellena el formulario del footer. Envía a la server action en la página que esté leyendo.
  2. La action comprueba la casilla de consentimiento y luego verifica el token de Turnstile con Cloudflare.
  3. Si falla alguna de las dos comprobaciones, el formulario vuelve con el mensaje y el email escrito. No se envía nada.
  4. Si no, la action envía la suscripción a Ikiform con la clave secreta y el Origin del navegador.
  5. La respuesta de Ikiform se convierte en el estado de la action, y el formulario se sustituye por la línea que pide revisar la bandeja de entrada.
  6. Por separado, Ikiform envía un webhook firmado por cada suscripción que no es spam. El route handler lo verifica y añade al lector a un formulario de Kit, cuyo incentive email es la confirmación de Kit. Pruébalo una vez con una dirección tuya; la guía de Kit explica por qué.
La action nunca llama a Kit por sí misma. Ikiform responde al spam con el mismo éxito que a una suscripción real, así que solo el webhook sabe qué suscripciones son reales.

Configuración del proyecto

Árbol del proyecto
app/
  layout.tsx
  newsletter/
    actions.ts
    newsletter-form.tsx
    submit-button.tsx
    page.tsx
  api/ikiform/route.ts
lib/
  turnstile.ts
.env.local

Crea una clave secreta en API keys con el scope submissions:write. Crea un widget de Turnstile en el panel de Cloudflare para tu dominio y localhost. Solo la site key puede llevar el prefijo NEXT_PUBLIC_; los demás valores deben quedarse solo en el servidor.

.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567

Pega la definición de campos de la guía principal en la pestaña Fields del formulario, para que Ikiform exija la casilla obligatoria consent llegue lo que llegue.

Constrúyelo

La comprobación de Turnstile

El endpoint siteverify de Cloudflare recibe el secreto, el token y, opcionalmente, la IP del visitante. Un token es válido durante 300 segundos y solo se puede comprobar una vez.

lib/turnstile.ts
import "server-only";

const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";

export async function passesTurnstile(token: string, visitor?: string) {
  if (!token) return false;
  const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
  if (visitor) body.set("remoteip", visitor);
  const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
  const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
  return outcome?.success === true;
}

La server action

app/newsletter/actions.ts
"use server";

import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";

export interface SignupState {
  status: "idle" | "subscribed" | "error";
  field?: "email" | "consent" | "check";
  message: string;
  email: string;
  firstName: string;
}

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get("email") ?? "").trim();
  const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
  const kept = { email, firstName };

  if (formData.get("consent") !== "true") {
    return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
  }

  const request = await headers();
  const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
  if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
    return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
  }

  const source = String(formData.get("source") ?? "");
  let response: Response;
  try {
    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: request.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        first_name: firstName || undefined,
        consent: true,
        source: PLACEMENTS.includes(source) ? source : "footer",
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) {
    return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
  }
  const emailError = result?.error?.fields?.email?.[0];
  if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
  return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}
  • El consentimiento se comprueba primero porque no cuesta nada. La llamada a Turnstile va después.
  • source se compara con una lista fija. Un envío manipulado no puede llenar de basura tu informe de ubicaciones.
  • 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 error devuelve el email y el nombre escritos. React reinicia un formulario después de una action, y defaultValue los vuelve a poner.
  • El honeypot se reenvía sin tocarlo. Ikiform guarda como spam uno relleno y responde con éxito, así que un bot no aprende nada.
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.

El formulario y su estado de envío

app/newsletter/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}
app/newsletter/newsletter-form.tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";

declare global {
  interface Window {
    turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
  }
}

const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };

export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
  const [state, action] = useActionState(subscribe, initial);
  const id = useId();
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mountCheck() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
    });
  }

  useEffect(mountCheck, []);
  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  if (state.status === "subscribed") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
      <label htmlFor={`${id}-email`}>Email</label>
      <input
        id={`${id}-email`}
        name="email"
        type="email"
        required
        autoComplete="email"
        defaultValue={state.email}
        aria-invalid={state.field === "email"}
      />
      <label htmlFor={`${id}-name`}>First name (optional)</label>
      <input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={state.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 />
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <SubmitButton />
    </form>
  );
}
  • useFormStatus debe leerse en un hijo del <form>, por eso el botón es un componente propio.
  • useId mantiene únicos los ids de los inputs cuando el formulario del footer y el de una página /newsletter están en pantalla a la vez.
  • El script de Turnstile se carga con ?render=explicit. render pone el widget dentro del formulario, que luego envía un input oculto cf-turnstile-response. El renderizado explícito también funciona con formularios que se montan tras una navegación del lado del cliente.
  • Un token solo se puede comprobar una vez, así que el widget se reinicia tras cada error. Sin el reinicio, el segundo intento siempre falla.

Ponlo en el layout

app/layout.tsx
import { NewsletterForm } from "./newsletter/newsletter-form";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        {children}
        <footer>
          <p>One email a month about what we shipped. No tracking pixels.</p>
          <NewsletterForm source="footer" />
        </footer>
      </body>
    </html>
  );
}

Usa source="post" al final de los artículos y source="newsletter-page" en una página dedicada. LayoutProps es el tipo con conocimiento de rutas que genera Next.js para los layouts.

Reenvía los suscriptores a Kit

El route handler de abajo recibe el webhook de Ikiform. Primero lee el cuerpo como texto, porque la firma cubre los bytes exactos. Añade su URL en Webhooks, elige el formulario de la newsletter y copia el secreto whsec_. La guía de Kit cubre las etiquetas y lo que significa cada respuesta de Kit.

app/api/ikiform/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function kit(path: string, body: Record<string, unknown>) {
  return fetch(`https://api.kit.com/v4${path}`, {
    method: "POST",
    headers: {
      "X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(body),
  });
}

export async function POST(request: 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, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const created = await kit("/subscribers", {
    email_address: email,
    first_name: typeof first_name === "string" ? first_name : null,
    state: "inactive",
  });
  const added = created.ok
    ? await kit(`/forms/${process.env.KIT_FORM_ID}/subscribers`, { email_address: email })
    : created;

  if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
  • Si tienes un proxy.ts, antes middleware.ts, que redirige a los visitantes sin sesión, deja /api/ikiform fuera de su matcher. Si no, cada entrega recibe tu redirección de inicio de sesión en lugar del handler.
  • Con trailingSlash: true en next.config, Next.js redirige /api/ikiform a /api/ikiform/. Registra la URL con la barra.
  • Un 503 con Retry-After hace que Ikiform espere y vuelva a intentarlo, y un 502 pone el estado de Kit en el registro de entregas.

Renderizado y mejora progresiva

Nada en el formulario lee la petición durante el renderizado, así que el layout no vuelve dinámicas tus páginas estáticas. La action se ejecuta en cada petición sea cual sea el modo de renderizado de la página. Como la action va directamente en action, React renderiza un envío de formulario real, y un lector que envía antes de que cargue el JavaScript sigue llegando a la action. El widget de Turnstile sí necesita JavaScript, así que con los scripts desactivados el lector recibe el mensaje de la verificación humana en lugar de suscribirse. Si ese equilibrio no le conviene a tus lectores, quita la comprobación y confía en el honeypot y la casilla obligatoria.

Despliegue

HostQué hacer
VercelAñade las seis variables en Environment Variables del proyecto. Apunta el webhook de Ikiform a tu dominio de producción; los despliegues de preview pueden estar detrás de la Deployment Protection de Vercel, que rechaza el webhook.
NetlifyNetlify hace el build de Next.js con su propio adaptador. Añade las variables en los ajustes de entorno del sitio, limitadas a Functions, y vuelve a desplegar.
CloudflareEjecuta Next.js en Workers mediante OpenNext. Define las variables en los ajustes del Worker y dale al Worker un dominio propio: Ikiform rechaza las URLs de webhook workers.dev.

Pruébalo gratis en localhost

Un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Gracias al Origin reenviado, next dev en http://localhost:3000 cuenta como tal. Usa las claves de prueba de Turnstile que publica Cloudflare en .env.local para que la comprobación siempre pase en tu máquina. Los webhooks no pueden llegar a localhost; para probar el route handler, exponlo con un túnel que dé una URL HTTPS, o usa Send test en el panel cuando esté desplegado.

Comprueba que funciona

  1. Ejecuta next dev, baja hasta el footer y suscríbete con una dirección tuya. La línea que pide revisar la bandeja de entrada sustituye al formulario.
  2. Abre Submissions. La suscripción tiene consent en true y source en footer.
  3. Quita required de la casilla en las herramientas de desarrollo y envía sin marcarla. Aparece el mensaje de consentimiento y no se guarda nada.
  4. Bloquea challenges.cloudflare.com en las herramientas de desarrollo y envía. Aparece el mensaje de la verificación humana y no se guarda nada.
  5. Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra succeeded con la respuesta de tu servidor.
  6. Abre Kit. El suscriptor está ahí como inactivo, y el email de confirmación de Kit está en tu bandeja de entrada.
  7. Envía con el honeypot relleno, _gotcha=bot. Igualmente recibe una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook.

Errores comunes

ProblemaQué hacer
402 plan_required en todas las pruebas localesEl envío llegó a Ikiform sin un origen localhost. Revisa la línea de Origin en la action, y abre la app en localhost, no en tu dirección de red.
La verificación humana falla en todos los intentosLa site key y el secreto son de widgets distintos, o el widget no está configurado para este hostname. Las claves de prueba solo pasan con el secreto de prueba.
Todas las páginas estáticas se volvieron dinámicas al añadir el footerAlgo en el formulario o en el layout llama a headers() o cookies() al renderizar. Lee la petición solo dentro de la action.
Failed to find Server Action, justo después de un despliegueUna pestaña sigue ejecutando el build anterior, cuyos ids de action han cambiado. El lector recarga y funciona.
Las entregas del webhook muestran 307 o 308Una redirección del proxy o el ajuste de la barra final atrapó /api/ikiform. Exclúyelo del matcher o registra la URL con la barra.
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.