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.
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.
- El lector envía el formulario del footer. use:enhance lo manda en segundo plano a la action subscribe de /newsletter.
- La action comprueba la casilla de consentimiento y luego cuenta los intentos recientes desde la IP del lector.
- Por encima del límite, devuelve fail(429) y no se envía nada.
- Si no, envía la suscripción a Ikiform con la clave secreta y el Origin del navegador.
- 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.
Configuración del proyecto
src/
env.ts
lib/
NewsletterForm.svelte
server/limit.ts
routes/
+layout.svelte
newsletter/
+page.server.ts
+page.svelte
api/ikiform/+server.ts
.envSvelteKit 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.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
IKIFORM_WEBHOOK_SECRET: {},
KIT_API_KEY: {},
KIT_FORM_ID: {},
});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=1234567Crea 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.
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
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 enpage.form. fail()conserva el email escrito. SvelteKit 3 también envía por la red el estado defail()en los formularios mejorados, así que la pestaña de red muestra422o429.- La línea de
Originreenvía el origen del navegador, porque Ikiform reconoce por esa cabecera las pruebas gratuitas desdelocalhosty elfetchde un servidor no envía ninguna. prerender = falseimporta si tu layout raíz lo prerenderiza todo: las páginas con actions no se pueden prerenderizar.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El componente del formulario
<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: falsemantiene al lector en el artículo.refreshAll: falsese salta volver a ejecutar todas las funcionesloadtras una suscripción correcta, que no cambia nada de lo que cargan.reset: falseconserva 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, ypage.formlleva allí el resultado.
La página /newsletter
<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.
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
| 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
- 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. - Desactiva JavaScript y vuelve a suscribirte. Llegas a
/newslettercon la misma línea. - Envía cuatro veces en diez minutos. El cuarto muestra el mensaje del límite, y la pestaña de red muestra
429. - Abre Submissions en el panel. El nuevo envío está ahí con todos los campos que enviaste.
- Si añadiste un webhook, abre el endpoint en Webhooks. La entrega muestra
succeededcon la respuesta de tu servidor. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.