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.
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:
| Detalle | Efecto en la lista de espera |
|---|---|
routeRules con prerender: true | La landing page se genera una vez como HTML. La ruta de servidor sigue ejecutándose en cada petición |
readBody también analiza cuerpos urlencoded | Un <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.
- La landing page es HTML estático. Después de montarse, el componente copia ?ref= o ?utm_source= en su valor de origen.
- 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.
- La ruta rechaza los envíos entre sitios, valida el cuerpo con Zod y reenvía el registro a Ikiform con la clave secreta.
- Un envío JSON recibe { joined: true }, o un error cuyo mensaje muestra el componente bajo el formulario.
- 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.
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:
app/
pages/index.vue
pages/thanks.vue
components/WaitlistForm.vue
server/
api/waitlist.post.ts
nuxt.config.ts
.envexport 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.
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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.tslimita la ruta aPOST. Cualquier otro método recibe405. Sec-Fetch-Sitevalesame-originpara tus propias páginas ycross-sitepara un formulario en otro sitio. Lo ponen los navegadores y los scripts de la página no pueden cambiarlo..catch()de Zod convierte unroleorefincorrecto 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
Origindel navegador se reenvía porque Ikiform cuenta con ella los envíos de prueba gratuitos desdelocalhost, y elfetchpropio 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
<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
methodyaction. Antes de la hidratación, el navegador lo envía urlencoded; después,@submit.preventse lo pasa a$fetch, que envía JSON. onMountedlee 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$fetchdeja fuera del cuerpo JSON. - El error de
$fetchlleva el mensaje de la ruta endata.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.
| Hosting | Configuración | El secreto |
|---|---|---|
| Vercel | Sin configuración. Vercel detecta Nitro al importar | NUXT_IKIFORM_SECRET_KEY en Environment Variables |
| Netlify | Sin configuración. Nuxt detecta la build de Netlify | NUXT_IKIFORM_SECRET_KEY en Environment variables |
| Cloudflare Pages | Sin configuración con la integración de Git. Para una subida directa, haz la build con --preset=cloudflare_pages | NUXT_IKIFORM_SECRET_KEY como variable cifrada en los ajustes del proyecto |
Comprueba que funciona
- Ejecuta
nuxt dev, abrehttp://localhost:3000/?ref=test, elige un rol y regístrate. La línea de agradecimiento sustituye al formulario. - Abre Submissions en el panel. El registro tiene
refentesty el rol que elegiste. - Desactiva JavaScript y regístrate sin rol. Llegas a
/thanks, y el envío no tienerole. - Con JavaScript desactivado, envía
a@b. Llegas a/?problem=email, y el mensaje aparece cuando vuelves a activar JavaScript y recargas. - 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
nuxt 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 |
|---|---|
404 en /api/waitlist en producción | El 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 ref | La query se lee durante el renderizado. Léela en onMounted, como arriba; el HTML prerenderizado nunca tiene query string. |
Todos los envíos responden failed | La clave está vacía. La variable debe llamarse NUXT_IKIFORM_SECRET_KEY para rellenar runtimeConfig.ikiformSecretKey. |
422 con unknown_field para role o ref | La definición de campos no los incluye. Añade ambos, 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
Sí. Dale al formulario un method y un action que apunten a una ruta de servidor. El readBody de Nitro analiza el envío urlencoded y la ruta puede responder con una redirección, mientras que los envíos con $fetch desde la página hidratada reciben JSON.
Sí. Prerenderiza la página con routeRules y mantén activa la ruta de servidor del formulario, y luego haz la build con nuxt build. Lee los valores de la query, como ref, después de montar la página.
En una clave privada de runtimeConfig, rellenada desde una variable de entorno NUXT_ en el hosting. Las claves privadas solo se pueden leer en el servidor, mediante useRuntimeConfig.
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.