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

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

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

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

Diagrama: Cómo vuelve una suscripción del footer de SvelteKit a la página en la que está el lector

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

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

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

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
  IKIFORM_WEBHOOK_SECRET: {},
  KIT_API_KEY: {},
  KIT_FORM_ID: {},
});
```

`.env`:

```bash
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](https://ikiform.com/es/guides/newsletter-signup.md#fields) 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`:

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

```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](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.

### El componente del formulario

`src/lib/NewsletterForm.svelte`:

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

```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](https://ikiform.com/es/guides/newsletter-signup/kit.md) explica cada respuesta de Kit y añade etiquetas.

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

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

## Despliegue

| Host | Qué hacer |
| --- | --- |
| Vercel | `adapter-vercel` ejecuta las actions como funciones. SvelteKit 3 elimina ahí el runtime edge, así que usa el predeterminado. Define las variables en **Environment Variables**. |
| Netlify | `adapter-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. |
| Cloudflare | `adapter-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

| Problema | Qué hacer |
| --- | --- |
| Enviar el formulario del footer salta a `/newsletter` incluso con JavaScript | El 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 prerenderizar | Un 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 resultado | Leen el mismo `page.form`. Compara su `source` con el del componente, como hace el ejemplo. |
| `402 plan_required` en las pruebas locales | El 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 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

### ¿Cómo pongo una form action de SvelteKit en el layout?

No puedes definirla ahí; las actions van en un archivo +page.server. Pon la action en una página como /newsletter y envía a ella desde el layout con action="/newsletter?/subscribe". Usa use:enhance con update({ navigate: false }) para quedarte en la página actual.

### ¿Puede tener un formulario de newsletter un sitio SvelteKit prerenderizado?

Sí. Prerenderiza los artículos y deja al servidor solo la página con la action y la ruta del webhook. Un sitio sin ningún servidor puede, en cambio, enviar desde el navegador directamente a Ikiform.

### ¿Por qué el suscriptor llega a Kit mediante un webhook?

Porque solo el webhook sabe quién es real. La form action recibe la misma respuesta de éxito para un bot que para un lector, mientras que el webhook solo se dispara para las suscripciones que pasan el filtro de spam de Ikiform, así que Kit nunca ve los bots.

### ¿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.
- [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.
- [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 Nuxt](https://ikiform.com/es/guides/newsletter-signup/nuxt.md): Un formulario de newsletter en Nuxt que funciona con o sin JavaScript: una server route con comprobación de Turnstile envía a Ikiform, y una ruta de Nitro alimenta beehiiv.
- [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.
- [MailerLite](https://ikiform.com/es/guides/newsletter-signup/mailerlite.md): Conecta tu propio formulario de suscripción con MailerLite: un relay añade a los lectores nuevos como no confirmados para el doble opt-in y nunca pide a un suscriptor que confirme dos veces.
