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

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

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](https://ikiform.com/es/guides/waitlist.md#form) 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(...)`.

Diagrama: El primer ?ref= de un visitante de SvelteKit le acompaña hasta el registro

1. Un visitante llega a un post del blog desde un enlace con ?ref=news.
2. El hook handle ve la etiqueta, no encuentra todavía una cookie waitlist_ref y pone una durante 30 días.
3. Lee la página de precios y vuelve a la de inicio. Ninguna de las dos URL tiene etiqueta; la cookie sí.
4. Se registra. La action join lee waitlist_ref y reenvía el registro a Ikiform con ref en news.
5. 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.

El origen vive en una cookie httpOnly, no en el formulario, así que sobrevive a la navegación y no se puede editar en la página.

## Prepara el proyecto

Crea el formulario y una clave secreta como en la [guía de SvelteKit de la documentación](https://ikiform.com/es/docs/guides/sveltekit.md). Esta página usa SvelteKit 3. Los archivos:

`Proyecto`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    +page.server.ts
    +page.svelte
    blog/[slug]/+page.svelte
vite.config.ts
.env
```

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

`src/env.ts`:

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

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

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** y su verificación humana. Todos los envíos llegan entonces desde 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.

La action envía `email` y `ref`. Si el formulario tiene una definición de campos en su pestaña **Fields**, incluye ambos:

`Campos`:

```json
{
  "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

`src/hooks.server.ts`:

```ts
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_length` de Ikiform, así que un enlace fabricado no puede guardar basura.
- `httpOnly` mantiene la cookie lejos de los scripts de la página. `sameSite: "lax"` sigue enviándola en el `POST` del propio formulario.
- En SvelteKit 3, el `path` de una cookie es `/` por defecto, así que llega a todas las rutas sin configurarlo.

### La action con nombre

`src/routes/+page.server.ts`:

```ts
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 `Origin` del navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos desde `localhost`, y el `fetch` propio 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 a `fail()`, así que `page.status` vale `400` o `429`.
- `load` lee `waitlist_joined` para 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

`src/routes/+page.svelte`:

```svelte
<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 con `form` definido.
- `use:enhance` envía con `fetch` en 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.
- `pending` desactiva el botón hasta que la action responde, lo que evita un doble envío en un teléfono lento.
- `data.joined` viene de `load` y `form.joined` de 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

1. Ejecuta `vite dev`, abre `http://localhost:5173/?ref=test`, luego quita la query y recarga. La cookie `waitlist_ref` sigue valiendo `test` en las herramientas de desarrollo del navegador.
2. Regístrate. La línea de agradecimiento sustituye al formulario sin recargar, y el registro en **Submissions** tiene `ref` en `test`.
3. Recarga la página. Aparece la línea de agradecimiento, no el formulario.
4. 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.
5. 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.
6. Los envíos desde `vite dev` llevan un origen `localhost`, 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 desde `localhost`. 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](https://ikiform.com/es/pricing).

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

### ¿Cómo registro el origen UTM en un formulario de SvelteKit?

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.

### ¿Se puede prerenderizar una página de SvelteKit con una form action?

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.

### ¿La cookie waitlist_joined evita los registros duplicados?

Solo en ese navegador. Ikiform no deduplica por email, así que quita las repeticiones donde viva la lista o al exportarla.

### ¿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 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan.

## Más guías

- [Lista de espera para un nuevo SaaS](https://ikiform.com/es/guides/waitlist.md): Crea una lista de espera previa al lanzamiento en tu propia landing page: el formulario, el antispam, los duplicados, los emails de confirmación y una exportación limpia el día del lanzamiento.
- [Lista de espera en Next.js](https://ikiform.com/es/guides/waitlist/nextjs.md): Una lista de espera con el App Router de Next.js: una server action que reenvía registros con una clave secreta, useActionState, un agradecimiento optimista y un límite por visitante.
- [Lista de espera en Astro](https://ikiform.com/es/guides/waitlist/astro.md): Una lista de espera en Astro sobre una landing page estática: una Action reenvía cada registro, un script la llama y una página bajo demanda recoge los envíos sin JavaScript.
- [Lista para lanzar en Product Hunt](https://ikiform.com/es/guides/waitlist/product-hunt.md): Reúne una lista semanas antes de Product Hunt, etiqueta cada registro por canal y envía el email del lanzamiento en dos tandas según el reinicio en hora del Pacífico.
- [Registro a una beta con filtro](https://ikiform.com/es/guides/waitlist/beta-signup.md): Un formulario de solicitud para una beta con preguntas de filtro que aplica el servidor, y un script que puntúa a los candidatos y elige cada grupo de la lista.
- [Lista de espera para app móvil](https://ikiform.com/es/guides/waitlist/mobile-app.md): Una lista de espera previa al lanzamiento para una app de iOS o Android que pregunta qué teléfono se usa, lleva a los testers de iPhone a TestFlight y llena una prueba cerrada de Play.
