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

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

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

Diagrama: Una ruta de Nitro responde a $fetch con JSON y a un envío normal con una redirección

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](https://ikiform.com/es/docs/guides/nuxt.md). Los archivos de esta página, con la estructura `app/` de Nuxt 4:

`Proyecto`:

```text
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```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`:

```bash
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](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.

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`:

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

```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`:

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

| 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

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](https://ikiform.com/es/pricing).

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

### ¿Puede funcionar un formulario de Nuxt sin JavaScript?

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.

### ¿Puedo prerenderizar una página de Nuxt que tiene un formulario de lista de espera?

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.

### ¿Dónde guardo la clave secreta en Nuxt?

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.

### ¿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 SvelteKit](https://ikiform.com/es/guides/waitlist/sveltekit.md): 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ó.
- [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 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.
- [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.
