Lista de espera para un nuevo SaaS8 min de lectura
Crea una lista de espera en SvelteKit
Una lista de espera en SvelteKit 3: un hook guarda el primer ?ref= en una cookie, una form action con nombre reenvía el registro y quien vuelve ve que ya se unió.
Lee ?ref= en hooks.server.ts en la primera petición del visitante y guárdalo en una cookie, para que el registro se atribuya al enlace que lo trajo aunque navegue por el sitio. Una form action con nombre lee la cookie, reenvía el registro a tu backend de formularios con una clave secreta y pone una segunda cookie para que la página salude al visitante que vuelve en lugar de pedírselo otra vez.
La guía principal tiene una pestaña de SvelteKit: una action por defecto y use:enhance, con ref fijo en homepage. Esta página añade lo que necesita un lanzamiento real. Los visitantes llegan a un post del blog desde un newsletter, leen un rato y se registran dos páginas después. Quieres que ese registro se atribuya al newsletter, no a la página donde escribieron su email.
Qué cambia en SvelteKit
Dos funciones de SvelteKit encajan bien aquí, y una regla condiciona la configuración:
| Función | Qué hace aquí |
|---|---|
handle en hooks.server.ts | Se ejecuta antes de cada petición renderizada en el servidor, así que puede ver ?ref= en cualquier página a la que llegue el visitante |
event.cookies | Guarda el primer origen durante 30 días. La action lo vuelve a leer, sin un input oculto que se pueda manipular |
Form actions con use:enhance | Envía sin JavaScript y mejora cuando carga, con un único bloque de código de servidor |
| La regla | Una página con actions no se puede prerenderizar, porque un servidor tiene que responder a su POST |
Un input oculto no puede hacer esto. Solo conoce la página en la que está el formulario, y una etiqueta en la URL desaparece tras el primer clic. Una cookie puesta en el servidor la conserva en todas las páginas, y el visitante nunca la ve ni la edita.
Esto es atribución al primer contacto: gana el primer enlace etiquetado. Una visita posterior desde otro enlace no lo sobrescribe. Si prefieres atribuirlo al último enlace, quita la comprobación !event.cookies.get(...).
- Un visitante llega a un post del blog desde un enlace con ?ref=news.
- El hook handle ve la etiqueta, no encuentra todavía una cookie waitlist_ref y pone una durante 30 días.
- Lee la página de precios y vuelve a la de inicio. Ninguna de las dos URL tiene etiqueta; la cookie sí.
- Se registra. La action join lee waitlist_ref y reenvía el registro a Ikiform con ref en news.
- Si va bien, la action pone waitlist_joined, así que en su próxima visita la página de inicio muestra la línea de agradecimiento en lugar del formulario.
Prepara el proyecto
Crea el formulario y una clave secreta como en la guía de SvelteKit de la documentación. Esta página usa SvelteKit 3. Los archivos:
src/
env.ts
hooks.server.ts
routes/
+page.server.ts
+page.svelte
blog/[slug]/+page.svelte
vite.config.ts
.envDeclara el secreto
SvelteKit 3 marcó como obsoletos los módulos $env/... en favor de variables declaradas. Declara la clave en src/env.ts; las variables son privadas salvo que las marques como públicas, y $app/env/private no se puede importar en código del navegador.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY en variables de entorno solo de servidor.La action envía email y ref. Si el formulario tiene una definición de campos en su pestaña Fields, incluye ambos:
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}Constrúyelo
Captura el origen
import type { Handle } from "@sveltejs/kit/hooks";
const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;
export const handle: Handle = async ({ event, resolve }) => {
const query = event.url.searchParams;
const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();
if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
}
return resolve(event);
};- La etiqueta se pasa a minúsculas y debe cumplir el mismo patrón que permite el
max_lengthde Ikiform, así que un enlace fabricado no puede guardar basura. httpOnlymantiene la cookie lejos de los scripts de la página.sameSite: "lax"sigue enviándola en elPOSTdel propio formulario.- En SvelteKit 3, el
pathde una cookie es/por defecto, así que llega a todas las rutas sin configurarlo.
La action con nombre
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;
export const load: PageServerLoad = ({ cookies }) => ({
joined: cookies.get("waitlist_joined") === "yes",
});
export const actions = {
join: async ({ request, cookies, fetch, url }) => {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });
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,
ref: cookies.get("waitlist_ref") ?? "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
const result = await response.json().catch(() => null);
if (!result?.ok) {
return fail(response.status === 429 ? 429 : 400, {
email,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
});
}
cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
return { joined: true };
},
} satisfies Actions;- Una action con nombre,
join, deja sitio para otras en esta página más adelante. SvelteKit no permite una action por defecto junto a otras con nombre. - La cabecera
Origindel navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos desdelocalhost, y elfetchpropio de la action no envía ninguna. fail()devuelve el email escrito y un mensaje. En SvelteKit 3, un envío mejorado usa el estado que pasas afail(), así quepage.statusvale400o429.loadleewaitlist_joinedpara que un visitante que vuelve vea que ya está dentro. Es una cortesía para un navegador, no una deduplicación; Ikiform sigue guardando un segundo registro desde otro dispositivo.
La página
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { data, form }: PageProps = $props();
let pending = $state(false);
</script>
<h1>Acme is almost here</h1>
{#if data.joined || form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
action="?/join"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}action="?/join"da nombre a la action. Sin JavaScript, el navegador envía ahí y SvelteKit renderiza la página conformdefinido.use:enhanceenvía confetchen cuanto la página está hidratada.update({ reset: false })conserva el email en el input tras un error, así que el visitante corrige una errata en lugar de volver a escribirlo.pendingdesactiva el botón hasta que la action responde, lo que evita un doble envío en un teléfono lento.data.joinedviene deloadyform.joinedde la action. Comprobar ambos cubre a un visitante que vuelve y a un registro nuevo con una sola condición.
El honeypot llega a Ikiform sin tocar, así que los bots que lo rellenan se guardan como spam. Los envíos con la clave secreta se saltan la verificación humana de Ikiform, así que activar Spam check no aporta nada aquí.
Prerenderizado y despliegue
La página de inicio tiene una action, así que debe renderizarse en el servidor. Los posts del blog y otros contenidos se pueden prerenderizar con export const prerender = true, pero handle no se ejecuta cuando un visitante abre un archivo prerenderizado; se ejecutó una vez durante el build. Un enlace etiquetado directo a una página prerenderizada no pone ninguna cookie. Apunta los enlaces de campaña a una página renderizada en el servidor, como la de inicio.
| Hosting | Adaptador | Notas |
|---|---|---|
| Vercel | @sveltejs/adapter-vercel | SvelteKit 3 eliminó aquí el runtime edge, así que la action se ejecuta en funciones de Node |
| Netlify | @sveltejs/adapter-netlify | Configura la clave en Environment variables |
| Cloudflare | @sveltejs/adapter-cloudflare | Necesita wrangler 4.118 o posterior, la peer dependency del adaptador. Configura la clave con wrangler secret put IKIFORM_SECRET_KEY |
Comprueba que funciona
- Ejecuta
vite dev, abrehttp://localhost:5173/?ref=test, luego quita la query y recarga. La cookiewaitlist_refsigue valiendotesten las herramientas de desarrollo del navegador. - Regístrate. La línea de agradecimiento sustituye al formulario sin recargar, y el registro en Submissions tiene
refentest. - Recarga la página. Aparece la línea de agradecimiento, no el formulario.
- Borra las cookies, desactiva JavaScript y envía
a@b. La página vuelve con el error y la dirección todavía en el input. - Envía con el honeypot relleno,
_gotcha=bot. Sigue recibiendo una respuesta de éxito, pero el envío se guarda como spam y no manda email ni webhook. - Los envíos desde
vite devllevan un origenlocalhost, así que cuentan como envíos de prueba. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desdelocalhost. Para pasar a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una sola vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
Problemas habituales
| Problema | Qué hacer |
|---|---|
| La build falla diciendo que una página con actions no se puede prerenderizar | Un layout pone prerender = true en todas las rutas. Pon export const prerender = false en el +page.server.ts de la página de la lista de espera. |
ref siempre es direct | El enlace etiquetado apuntaba a una página prerenderizada, así que handle nunca lo vio, o una visita anterior ya había puesto la cookie. Revisa la cookie en las herramientas de desarrollo. |
Avisos de $env/static/private después de actualizar | SvelteKit 3 marcó como obsoletos los módulos $env. Declara la clave en src/env.ts e impórtala desde $app/env/private. |
422 con unknown_field | La definición de campos no incluye ref. Añádelo como en la definición de arriba. |
402 plan_required | El espacio de trabajo no tiene plan y el envío vino de un sitio en producción. Queda retenido, no se pierde. Elige un plan o compra un pack en Settings › Plan and usage y los envíos retenidos llegarán. |
Preguntas
Lee la query en el hook handle en la primera petición, guarda la etiqueta en una cookie httpOnly y lee la cookie en la form action. El origen sobrevive a la navegación y nunca aparece en el formulario.
No. Una página con actions necesita un servidor que responda a su POST, así que SvelteKit se niega a prerenderizarla. Prerenderiza las demás páginas y deja la del formulario renderizada en el servidor.
Solo en ese navegador. Ikiform no deduplica por email, así que quita las repeticiones donde viva la lista o al exportarla.
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 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.