Formulario de suscripción a newsletter

Formulario de suscripción a newsletter9 min de lectura

Crea un formulario de suscripción a newsletter en SvelteKit

Un formulario de newsletter en el layout de SvelteKit 3: una form action con consentimiento y un límite por IP, use:enhance que se queda en la página y una ruta de webhook para Kit.

Preet SutharFundador, Ikiform

Pon la form action en una página /newsletter y envía a ella desde un formulario en el layout raíz. use:enhance con update({ navigate: false }) aplica el resultado a la página en la que está el lector, así que el footer lo muestra sin salir del artículo, y sin JavaScript el mismo envío llega a /newsletter. La action comprueba el consentimiento, limita cada IP con getClientAddress() y reenvía la suscripción a tu backend de formularios, y una ruta +server.ts pasa a Kit las suscripciones verificadas.

La pestaña de SvelteKit de la guía principal envía desde un footer a una action en /newsletter y gestiona el resultado a mano. Esta página lo construye a la manera de SvelteKit 3: variables de entorno explícitas, update({ navigate: false }) para que el framework aplique el resultado, un límite por IP y una ruta de webhook para Kit. La configuración del producto está en la guía de SvelteKit de la documentación.

Qué cambia un formulario en el layout en SvelteKit

Las form actions solo viven en un +page.server.ts, nunca en un layout, así que un formulario en el footer envía a la action de otra página. SvelteKit 3 hizo que aquí los formularios mejorados se comporten como los nativos: un formulario cuya action está en otra página navega a esa página. Los widgets de newsletter son el caso que menciona la documentación para desactivarlo, con update({ navigate: false }). El resultado se aplica entonces a la página actual como page.form, que lee el footer.

Cómo vuelve una suscripción del footer de SvelteKit a la página en la que está el lectorformulariodelfooter+layout.svelte,cualquierpágina?/subscribeactionen/newslettergetClientAddress()3intentoscada10minutosIkiformclavesecreta,guardalasuscripciónpage.formenlapáginaactualfail(429)mensaje,noseenvíanadause:enhancepermitidonavigate: falsesobre el límiteCómo vuelve una suscripción del footer de SvelteKit a la página en la que está el lectorformulario delfooter+layout.svelte,cualquier página?/subscribeaction en /newslettergetClientAddress()3 intentos cada10 minutosIkiformclave secreta, guardala suscripciónpage.formen la página actualfail(429)mensaje, nose envíanadause:enhancepermitidonavigate: falsesobre el límite
  1. El lector envía el formulario del footer. use:enhance lo manda en segundo plano a la action subscribe de /newsletter.
  2. La action comprueba la casilla de consentimiento y luego cuenta los intentos recientes desde la IP del lector.
  3. Por encima del límite, devuelve fail(429) y no se envía nada.
  4. Si no, envía la suscripción a Ikiform con la clave secreta y el Origin del navegador.
  5. update con navigate false aplica el resultado a la página actual como page.form, y el footer lo muestra. Sin JavaScript, el navegador va a /newsletter, que renderiza el mismo componente.
Solo reacciona el formulario cuyo source coincide con el resultado, así que un footer y un formulario de página no cambian los dos.

Configuración del proyecto

Árbol del proyecto
src/
  env.ts
  lib/
    NewsletterForm.svelte
    server/limit.ts
  routes/
    +layout.svelte
    newsletter/
      +page.server.ts
      +page.svelte
    api/ikiform/+server.ts
.env

SvelteKit 3 declara obsoletos los módulos $env/* en favor de variables explícitas. Decláralas en src/env.ts; por defecto son privadas y se leen en tiempo de ejecución, y $app/env/private se niega a importarse en código del navegador.

src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
  IKIFORM_WEBHOOK_SECRET: {},
  KIT_API_KEY: {},
  KIT_FORM_ID: {},
});
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567

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.

Constrúyelo

Un límite por IP

Una persona se suscribe una vez. Tres intentos en diez minutos dejan margen para una errata y frenan a un script. getClientAddress() devuelve la IP que determinó tu adaptador, así que el limitador no necesita saber qué cabecera usa tu hosting.

src/lib/server/limit.ts
const WINDOW_MS = 10 * 60_000;
const LIMIT = 3;
const recent = new Map<string, number[]>();

export function allow(address: string, now = Date.now()) {
  const hits = (recent.get(address) ?? []).filter((time) => now - time < WINDOW_MS);
  if (hits.length >= LIMIT) return false;
  if (recent.size > 10_000) recent.clear();
  recent.set(address, [...hits, now]);
  return true;
}

El contador vive en la memoria de un servidor. En hostings serverless cada instancia cuenta por su cuenta, así que tómalo como un reductor de velocidad y llévalo a un almacén compartido cuando crezca el tráfico. Los archivos de un directorio server no se pueden importar desde código del navegador. SvelteKit 3 llega a src/lib mediante el alias #lib que un proyecto nuevo declara en imports dentro de package.json; el antiguo alias $lib ya no existe.

La action

src/routes/newsletter/+page.server.ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { allow } from "#lib/server/limit.js";
import type { Actions } from "./$types";

export const prerender = false;

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

export const actions = {
  subscribe: async ({ request, fetch, getClientAddress, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    const picked = String(form.get("source") ?? "");
    const source = PLACEMENTS.includes(picked) ? picked : "footer";
    const refuse = (status: number, field: string, message: string) =>
      fail(status, { newsletter: { ok: false, source, field, message, email } });

    if (form.get("consent") !== "true") return refuse(422, "consent", "Tick the box so we know you want the emails.");
    if (!allow(getClientAddress())) return refuse(429, "form", "Too many tries from this connection. Wait a few minutes.");

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({
        email,
        first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
        consent: true,
        source,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
    }).catch(() => null);

    const result = await response?.json().catch(() => null);
    if (result?.ok) return { newsletter: { ok: true, source } };
    const emailError = result?.error?.fields?.email?.[0];
    if (emailError) return refuse(422, "email", emailError);
    return refuse(response?.status ?? 502, "form", result?.error?.message ?? "We couldn't reach the list. Try again.");
  },
} satisfies Actions;
  • El resultado va envuelto en newsletter, así que nunca choca con el resultado de otro formulario en page.form.
  • fail() conserva el email escrito. SvelteKit 3 también envía por la red el estado de fail() en los formularios mejorados, así que la pestaña de red muestra 422 o 429.
  • 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.
  • prerender = false importa si tu layout raíz lo prerenderiza todo: las páginas con actions no se pueden prerenderizar.
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 componente del formulario

src/lib/NewsletterForm.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  type Outcome = { ok: boolean; source: string; field?: string; message?: string; email?: string };

  let { source = "footer" }: { source?: "footer" | "post" | "newsletter-page" } = $props();
  let pending = $state(false);
  const outcome = $derived.by(() => {
    const value = page.form?.newsletter as Outcome | undefined;
    return value?.source === source ? value : undefined;
  });
</script>

{#if outcome?.ok}
  <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
  <form
    method="POST"
    action="/newsletter?/subscribe"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false, reset: false });
        pending = false;
      };
    }}
  >
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" value={outcome?.email ?? ""} aria-invalid={outcome?.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={outcome?.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 />
    {#if outcome?.message}
      <p role="alert">{outcome.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
  </form>
{/if}
  • navigate: false mantiene al lector en el artículo. refreshAll: false se salta volver a ejecutar todas las funciones load tras una suscripción correcta, que no cambia nada de lo que cargan.
  • reset: false conserva el email escrito cuando hay un error. Si todo va bien, el formulario se sustituye de todos modos.
  • Con JavaScript desactivado, el envío va a /newsletter, que renderiza este mismo componente, y page.form lleva allí el resultado.

La página /newsletter

src/routes/newsletter/+page.svelte
<script lang="ts">
  import NewsletterForm from "#lib/NewsletterForm.svelte";
</script>

<svelte:head>
  <title>Newsletter</title>
</svelte:head>

<h1>The monthly newsletter</h1>
<p>What we shipped, what broke and what we learned. One email a month.</p>
<NewsletterForm source="newsletter-page" />

Renderiza <NewsletterForm /> en +layout.svelte para el footer, y pasa source="post" al final de los artículos.

Reenvía los suscriptores a Kit

Esta ruta +server.ts recibe el webhook firmado de Ikiform, crea el suscriptor en Kit como inactivo y lo añade a un formulario de Kit, cuyo incentive email es la confirmación de Kit; pruébalo una vez con una dirección tuya. Primero lee el cuerpo como texto porque la firma cubre los bytes exactos, y devuelve objetos Response normales, ya que SvelteKit 3 declara obsoletos los helpers json() y text(). La guía de Kit explica cada respuesta de Kit y añade etiquetas.

src/routes/api/ikiform/+server.ts
import { IKIFORM_WEBHOOK_SECRET, KIT_API_KEY, KIT_FORM_ID } from "$app/env/private";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request, fetch }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: 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 kit = (path: string, body: Record<string, unknown>) =>
    fetch(`https://api.kit.com/v4${path}`, {
      method: "POST",
      headers: { "X-Kit-Api-Key": KIT_API_KEY, "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    });

  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/${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 });
};

La protección CSRF de SvelteKit está siempre activa en la versión 3, pero solo rechaza los envíos de formulario desde otro origen y los posts desde otro origen sin Content-Type. Ikiform envía application/json, así que el webhook pasa. Añade la URL en Webhooks y copia el secreto whsec_.

Honeypot y verificación humana

El input _gotcha se reenvía e Ikiform guarda como spam uno relleno, con una respuesta de éxito y sin webhook, así que Kit nunca lo ve. La clave secreta se salta la comprobación de Turnstile de Ikiform; el límite por IP de arriba cubre el bucle que ejecutaría un script. Para los bots que usan muchas IP, verifica un token de Turnstile en la action, como muestra la guía de Turnstile.

Despliegue

HostQué hacer
Verceladapter-vercel ejecuta las actions como funciones. SvelteKit 3 elimina ahí el runtime edge, así que usa el predeterminado. Define las variables en Environment Variables.
Netlifyadapter-netlify sigue la Frameworks API de Netlify, y el directorio de publicación ahora es una opción del adaptador. Define las variables en los ajustes de entorno del sitio.
Cloudflareadapter-cloudflare genera para Workers y necesita Wrangler 4.118 o posterior, su dependencia peer. Su documentación recomienda $app/env para las variables. Usa un dominio propio para el webhook; Ikiform rechaza workers.dev y pages.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. El limitador también te cuenta a ti, así que reinicia el servidor de desarrollo si lo alcanzas mientras pruebas. Ikiform no entrega webhooks a localhost; expón el servidor de desarrollo mediante un túnel con URL HTTPS para probar la ruta.

Comprueba que funciona

  1. Ejecuta vite dev, abre cualquier página y suscríbete desde el footer. La línea aparece en el footer y la URL no cambia.
  2. Desactiva JavaScript y vuelve a suscribirte. Llegas a /newsletter con la misma línea.
  3. Envía cuatro veces en diez minutos. El cuarto muestra el mensaje del límite, y la pestaña de red muestra 429.
  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 Kit. El suscriptor está ahí como inactivo, y el email de confirmación de Kit está en tu bandeja de entrada.

Errores comunes

ProblemaQué hacer
Enviar el formulario del footer salta a /newsletter incluso con JavaScriptEl callback de use:enhance llama a update() sin navigate: false. En SvelteKit 3, las actions de otra página navegan por defecto.
El build falla porque /newsletter no se puede prerenderizarUn layout pone prerender = true. Las páginas con actions necesitan un servidor; pon prerender = false en el archivo de servidor de la página.
Tanto el footer como el formulario de la página muestran el resultadoLeen el mismo page.form. Compara su source con el del componente, como hace el ejemplo.
402 plan_required en las pruebas localesEl envío no tenía un origen localhost. Revisa la línea de Origin y abre el sitio en localhost.
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.