Lista de espera para un nuevo SaaS

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

Preet SutharFundador, Ikiform

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ónQué hace aquí
handle en hooks.server.tsSe 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.cookiesGuarda 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:enhanceEnvía sin JavaScript y mejora cuando carga, con un único bloque de código de servidor
La reglaUna 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(...).

El primer ?ref= de un visitante de SvelteKit le acompaña hasta el registroPrimer díaRegistro/blog/launch?ref=newsprimera visitahook handleponewaitlist_ref=news/pricing, luego /sin query stringaction ?/joinlee waitlist_refIkiformref = newswaitlist_joinedla próxima visitadice que ya estápeticiónnavegaJSONcookieEl primer ?ref= de un visitante de SvelteKit le acompaña hasta el registroPrimer díaRegistro/blog/launch?ref=newsprimera visitahook handlepone waitlist_ref=news/pricing, luego /sin query stringaction ?/joinlee waitlist_refIkiformref = newswaitlist_joinedla próxima visita dice que yaestápeticiónnavegaJSONcookie
  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. Esta página usa SvelteKit 3. Los archivos:

Proyecto
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
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
.env
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 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
{
  "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
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
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
<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.

HostingAdaptadorNotas
Vercel@sveltejs/adapter-vercelSvelteKit 3 eliminó aquí el runtime edge, así que la action se ejecuta en funciones de Node
Netlify@sveltejs/adapter-netlifyConfigura la clave en Environment variables
Cloudflare@sveltejs/adapter-cloudflareNecesita 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.

Problemas habituales

ProblemaQué hacer
La build falla diciendo que una página con actions no se puede prerenderizarUn 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 directEl 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 actualizarSvelteKit 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_fieldLa definición de campos no incluye ref. Añádelo como en la definición de arriba.
402 plan_requiredEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.