Lista de espera para un nuevo SaaS

Lista de espera para un nuevo SaaS8 min de lectura

Crea una lista de espera en Nuxt

Una lista de espera en Nuxt sobre una landing page prerenderizada: una ruta de Nitro acepta $fetch y envíos normales, los valida con Zod y redirige sin JavaScript.

Preet SutharFundador, Ikiform

Prerenderiza la landing page con routeRules y envía el formulario a una ruta de servidor de Nitro que valida el email y el rol con Zod y los reenvía a tu backend de formularios con una clave secreta de runtimeConfig. La ruta responde con JSON a $fetch y con una redirección 303 a un envío de formulario normal, así que el registro funciona antes de que la página se hidrate.

La guía principal tiene una pestaña de Nuxt: una ruta de servidor, runtimeConfig y un componente que envía con $fetch. Ese formulario necesita JavaScript, solo envía el email y sirve la página en cada petición. Esta versión prerenderiza la landing page, hace una pregunta para clasificar y sigue funcionando cuando el bundle no ha cargado.

Qué cambia en Nuxt

Nuxt renderiza en el servidor por defecto, y Nitro sirve la carpeta server/ junto a tus páginas. Tres detalles condicionan una lista de espera:

DetalleEfecto en la lista de espera
routeRules con prerender: trueLa landing page se genera una vez como HTML. La ruta de servidor sigue ejecutándose en cada petición
readBody también analiza cuerpos urlencodedUn <form> normal puede enviar a la misma ruta que $fetch, y también puede hacerlo un formulario en el sitio de otra persona
Una página prerenderizada no tiene query string en el build?ref= y ?problem= deben leerse después de montar la página, no durante el renderizado

La segunda fila corta en ambos sentidos. Es lo que hace funcionar el camino sin JavaScript, y es la razón de que la ruta compruebe la cabecera Sec-Fetch-Site antes de hacer nada: si no, un formulario en otro sitio podría enviar registros usando tu clave.

Una ruta de Nitro responde a $fetch con JSON y a un envío normal con una redirección/prerenderizadaonMountedlee?ref=server/api/waitlist.post.tscomprobaciónsame-site,ZodIkiformclavesecreta,loguarda{joined:true}$fetch,mismapágina303/thankso/?problem=emailpáginaprerenderizadamuestraelresultadoPOSTJSONcontent-type jsonurlencodedUna ruta de Nitro responde a $fetch con JSON y a un envío normal con una redirección/ prerenderizadaonMounted lee ?ref=server/api/waitlist.post.tscomprobaciónsame-site, ZodIkiformclave secreta, loguarda{ joined: true }$fetch, misma página303/thankso/?problem=emailpáginaprerenderizadamuestra elresultadoPOSTJSONcontent-type jsonurlencoded
  1. La landing page es HTML estático. Después de montarse, el componente copia ?ref= o ?utm_source= en su valor de origen.
  2. El formulario envía a la ruta de servidor: como JSON mediante $fetch cuando Vue se ha hidratado, o urlencoded como envío normal antes de eso.
  3. La ruta rechaza los envíos entre sitios, valida el cuerpo con Zod y reenvía el registro a Ikiform con la clave secreta.
  4. Un envío JSON recibe { joined: true }, o un error cuyo mensaje muestra el componente bajo el formulario.
  5. Un envío normal recibe un 303 a /thanks, o de vuelta a / con un código de problema que la página convierte en un mensaje tras montarse.
El tipo de contenido del envío decide la respuesta. Las dos páginas a las que llega el navegador están prerenderizadas.

Prepara el proyecto

Crea el formulario y la clave secreta como en la guía de Nuxt de la documentación. Los archivos de esta página, con la estructura app/ de Nuxt 4:

Proyecto
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
  routeRules: {
    "/": { prerender: true },
    "/thanks": { prerender: true },
  },
});

Un ikiformSecretKey vacío en runtimeConfig se rellena en tiempo de ejecución desde NUXT_IKIFORM_SECRET_KEY. Las claves fuera de public solo existen en el servidor. Añade Zod con npm install zod.

.env
NUXT_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.

Este formulario envía email, role y ref. Si añades una definición de campos en la pestaña Fields del formulario, incluye los tres, con los mismos valores de rol que el select:

Campos
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "role", "type": "select", "label": "What do you do?", "options": [{ "value": "founder", "label": "Founder" }, { "value": "engineer", "label": "Engineer" }, { "value": "designer", "label": "Designer" }, { "value": "other", "label": "Something else" }] },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}

Constrúyelo

La ruta de servidor

server/api/waitlist.post.ts
import { z } from "zod";

const Signup = z.object({
  email: z.email(),
  role: z.enum(["founder", "engineer", "designer", "other"]).optional().catch(undefined),
  ref: z.string().regex(/^[a-z0-9_-]{1,40}$/).catch("direct"),
  _gotcha: z.string().catch(""),
});

const MESSAGES = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

type Problem = keyof typeof MESSAGES;

export default defineEventHandler(async (event) => {
  if (getRequestHeader(event, "sec-fetch-site") === "cross-site") {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const plain = !getRequestHeader(event, "content-type")?.includes("application/json");

  const answer = (problem: Problem | null) => {
    if (plain) return sendRedirect(event, problem ? `/?problem=${problem}` : "/thanks", 303);
    if (problem) throw createError({ status: problem === "busy" ? 429 : 400, message: MESSAGES[problem] });
    return { joined: true };
  };

  const parsed = Signup.safeParse(await readBody(event));
  if (!parsed.success) return answer("email");

  const config = useRuntimeConfig(event);
  const origin = getRequestHeader(event, "origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(parsed.data),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return answer("failed");
  if (response.status === 429) return answer("busy");
  const result = await response.json().catch(() => null);
  if (result?.ok) return answer(null);
  return answer(result?.error?.fields?.email ? "email" : "failed");
});
  • El sufijo .post.ts limita la ruta a POST. Cualquier otro método recibe 405.
  • Sec-Fetch-Site vale same-origin para tus propias páginas y cross-site para un formulario en otro sitio. Lo ponen los navegadores y los scripts de la página no pueden cambiarlo.
  • .catch() de Zod convierte un role o ref incorrecto en un valor seguro en lugar de hacer fallar el registro. Solo un email incorrecto es un error que vale la pena mostrar.
  • 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 ruta no envía ninguna.
  • useRuntimeConfig(event) lee la clave en cada petición, así que un valor configurado en el hosting después del build también se recoge.
  • Una redirección lleva un código de problema corto, nunca el mensaje, así que un enlace fabricado no puede escribir texto en tu página.

El componente del formulario

app/components/WaitlistForm.vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

const route = useRoute();
const email = ref("");
const role = ref("");
const source = ref("direct");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");

onMounted(() => {
  const tag = String(route.query.ref ?? route.query.utm_source ?? "").toLowerCase().slice(0, 40);
  if (tag) source.value = tag;
  const problem = String(route.query.problem ?? "");
  if (MESSAGES[problem]) error.value = MESSAGES[problem];
});

async function join() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/waitlist", {
      method: "POST",
      body: { email: email.value, role: role.value || undefined, ref: source.value, _gotcha: trap.value },
    });
    status.value = "joined";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
  <form v-else method="post" action="/api/waitlist" @submit.prevent="join">
    <label for="email">Email</label>
    <input id="email" v-model="email" name="email" type="email" autocomplete="email" required />
    <label for="role">What do you do?</label>
    <select id="role" v-model="role" name="role">
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <input type="hidden" name="ref" :value="source" />
    <input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
  </form>
</template>
  • El formulario conserva method y action. Antes de la hidratación, el navegador lo envía urlencoded; después, @submit.prevent se lo pasa a $fetch, que envía JSON.
  • onMounted lee la query. Leerla durante el renderizado daría al HTML del build un valor vacío y al navegador otro distinto, un desajuste de hidratación.
  • Un rol vacío se envía como undefined, que $fetch deja fuera del cuerpo JSON.
  • El error de $fetch lleva el mensaje de la ruta en data.message, así que el componente muestra el texto del servidor.

Usa <WaitlistForm /> en app/pages/index.vue y escribe app/pages/thanks.vue con la línea de agradecimiento. El honeypot se reenvía 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í.

Notas de despliegue

Haz la build con nuxt build. nuxt generate prerenderiza todas las páginas como un sitio estático sin servidor, así que /api/waitlist nunca se ejecutaría. Las routeRules de arriba te dan páginas estáticas y una ruta activa con una sola build.

HostingConfiguraciónEl secreto
VercelSin configuración. Vercel detecta Nitro al importarNUXT_IKIFORM_SECRET_KEY en Environment Variables
NetlifySin configuración. Nuxt detecta la build de NetlifyNUXT_IKIFORM_SECRET_KEY en Environment variables
Cloudflare PagesSin configuración con la integración de Git. Para una subida directa, haz la build con --preset=cloudflare_pagesNUXT_IKIFORM_SECRET_KEY como variable cifrada en los ajustes del proyecto

Comprueba que funciona

  1. Ejecuta nuxt dev, abre http://localhost:3000/?ref=test, elige un rol y regístrate. La línea de agradecimiento sustituye al formulario.
  2. Abre Submissions en el panel. El registro tiene ref en test y el rol que elegiste.
  3. Desactiva JavaScript y regístrate sin rol. Llegas a /thanks, y el envío no tiene role.
  4. Con JavaScript desactivado, envía a@b. Llegas a /?problem=email, y el mensaje aparece cuando vuelves a activar JavaScript y recargas.
  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 nuxt 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
404 en /api/waitlist en producciónEl sitio se generó con nuxt generate, que no incluye servidor. Haz la build con nuxt build y conserva prerender en routeRules.
Un aviso de desajuste de hidratación en el input oculto refLa query se lee durante el renderizado. Léela en onMounted, como arriba; el HTML prerenderizado nunca tiene query string.
Todos los envíos responden failedLa clave está vacía. La variable debe llamarse NUXT_IKIFORM_SECRET_KEY para rellenar runtimeConfig.ikiformSecretKey.
422 con unknown_field para role o refLa definición de campos no los incluye. Añade ambos, 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.