# Monte uma lista de espera em Nuxt

> Uma lista de espera em Nuxt numa landing page pré-renderizada: uma rota Nitro aceita $fetch e envios de formulário simples, valida com Zod e redireciona sem JavaScript.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/waitlist/nuxt

Pré-renderize a landing page com `routeRules` e envie o formulário a uma server route do Nitro que valida o e-mail e a função com Zod e os encaminha ao seu backend de formulários com uma chave secreta do `runtimeConfig`. A rota responde JSON ao `$fetch` e um redirecionamento `303` a um envio de formulário simples, então a inscrição funciona antes de a página hidratar.

O [guia principal](https://ikiform.com/pt-br/guides/waitlist.md#form) tem uma aba de Nuxt: uma server route, `runtimeConfig` e um componente que envia com `$fetch`. Esse formulário precisa de JavaScript, envia só o e-mail e serve a página a cada requisição. Esta versão pré-renderiza a landing page, faz uma pergunta para organizar a lista e continua funcionando quando o bundle não carregou.

## O que muda no Nuxt

O Nuxt renderiza no servidor por padrão, e o Nitro serve a pasta `server/` ao lado das suas páginas. Três detalhes moldam uma lista de espera:

| Detalhe | Efeito na lista de espera |
| --- | --- |
| `routeRules` com `prerender: true` | A landing page é gerada uma vez como HTML. A server route continua rodando a cada requisição |
| `readBody` também interpreta corpos urlencoded | Um `<form>` simples pode enviar para a mesma rota que o `$fetch`, e um formulário no site de outra pessoa também |
| Uma página pré-renderizada não tem query string no build | `?ref=` e `?problem=` precisam ser lidos depois que a página monta, não durante a renderização |

A segunda linha corta dos dois lados. É ela que faz o caminho sem JavaScript funcionar, e é por isso que a rota confere o header `Sec-Fetch-Site` antes de qualquer coisa: senão, um formulário em outro site poderia enviar inscrições usando a sua chave.

Diagrama: Uma rota Nitro responde ao $fetch com JSON e a um envio simples com um redirecionamento

1. A landing page é HTML estático. Depois que ela monta, o componente copia ?ref= ou ?utm_source= para o valor de origem dele.
2. O formulário envia para a server route: como JSON via $fetch depois que o Vue hidratou, ou urlencoded como envio simples antes disso.
3. A rota recusa envios cross-site, valida o corpo com Zod e encaminha a inscrição ao Ikiform com a chave secreta.
4. Um envio JSON recebe { joined: true }, ou um erro cuja mensagem o componente mostra abaixo do formulário.
5. Um envio simples recebe um 303 para /thanks, ou de volta para / com um código de problema que a página transforma em mensagem depois de montar.

O content type do envio decide a resposta. As duas páginas em que o navegador cai são pré-renderizadas.

## Configure o projeto

Crie o formulário e a chave secreta como no [guia de Nuxt da documentação](https://ikiform.com/pt-br/docs/guides/nuxt.md). Os arquivos desta página, no layout `app/` do Nuxt 4:

`Projeto`:

```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 },
  },
});
```

Um `ikiformSecretKey` vazio no `runtimeConfig` é preenchido em runtime a partir de `NUXT_IKIFORM_SECRET_KEY`. Chaves fora de `public` só existem no servidor. Adicione o Zod com `npm install zod`.

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `IKIFORM_SECRET_KEY` em variáveis de ambiente só do servidor.

Este formulário envia `email`, `role` e `ref`. Se você adicionar uma definição de campos na aba **Fields** do formulário, liste os três, com os mesmos valores de função do 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 } }
  ]
}
```

## Construa

### A server route

`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");
});
```

- O sufixo `.post.ts` limita a rota a `POST`. Qualquer outro método recebe `405`.
- `Sec-Fetch-Site` é `same-origin` para as suas próprias páginas e `cross-site` para um formulário em outro lugar. Os navegadores o definem e os scripts da página não conseguem mudá-lo.
- O `.catch()` do Zod transforma um `role` ou `ref` inválido num valor seguro em vez de fazer a inscrição falhar. Só um e-mail inválido é um erro que vale a pena mostrar.
- O header `Origin` do navegador é repassado porque o Ikiform conta os envios de teste gratuitos de `localhost` por ele, e o `fetch` da própria rota não envia nenhum.
- `useRuntimeConfig(event)` lê a chave a cada requisição, então um valor definido no host depois do build ainda é pego.
- Um redirecionamento leva um código de problema curto, nunca a mensagem, para que um link forjado não consiga escrever texto na sua página.

### O componente do formulário

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

- O formulário mantém `method` e `action`. Antes da hidratação, o navegador o envia urlencoded; depois, o `@submit.prevent` o entrega ao `$fetch`, que envia JSON.
- O `onMounted` lê a query. Lê-la durante a renderização daria um valor vazio ao HTML do build e outro ao navegador, um hydration mismatch.
- Uma função vazia é enviada como `undefined`, que o `$fetch` deixa fora do corpo JSON.
- O erro do `$fetch` traz a mensagem da rota em `data.message`, então o componente mostra o texto do servidor.

Use `<WaitlistForm />` em `app/pages/index.vue` e escreva `app/pages/thanks.vue` com a frase de agradecimento. O honeypot é repassado intacto, então os bots que o preenchem são armazenados como spam. Envios com a chave secreta pulam a verificação humana do Ikiform, então ligar o **Spam check** não acrescenta nada aqui.

## Notas de deploy

Faça o build com `nuxt build`. O `nuxt generate` pré-renderiza todas as páginas como um site estático sem servidor, então `/api/waitlist` nunca rodaria. As `routeRules` acima dão páginas estáticas e uma rota ativa a partir de um único build.

| Host | Configuração | O segredo |
| --- | --- | --- |
| Vercel | Zero configuração. A Vercel detecta o Nitro na importação | `NUXT_IKIFORM_SECRET_KEY` em **Environment Variables** |
| Netlify | Zero configuração. O Nuxt detecta o build da Netlify | `NUXT_IKIFORM_SECRET_KEY` em **Environment variables** |
| Cloudflare Pages | Zero configuração com a integração com Git. Para um upload direto, faça o build com `--preset=cloudflare_pages` | `NUXT_IKIFORM_SECRET_KEY` como variável criptografada nas configurações do projeto |

## Confira se funciona

1. Rode `nuxt dev`, abra `http://localhost:3000/?ref=test`, escolha uma função e inscreva-se. A frase de agradecimento substitui o formulário.
2. Abra **Submissions** no painel. A inscrição tem `ref` definido como `test` e a função que você escolheu.
3. Desligue o JavaScript e inscreva-se sem função. Você cai em `/thanks`, e o envio não tem `role`.
4. Com o JavaScript desligado, envie `a@b`. Você cai em `/?problem=email`, e a mensagem aparece quando você liga o JavaScript de novo e recarrega.
5. Envie com o honeypot preenchido, `_gotcha=bot`. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook.
6. Envios do `nuxt dev` trazem a origem `localhost`, então contam como envios de teste. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de `localhost`. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os [preços](https://ikiform.com/pt-br/pricing).

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `404` em `/api/waitlist` em produção | O site foi gerado com `nuxt generate`, que não inclui servidor. Faça o build com `nuxt build` e mantenha `prerender` nas `routeRules`. |
| Um aviso de hydration mismatch no input oculto `ref` | A query é lida durante a renderização. Leia-a no `onMounted`, como acima; o HTML pré-renderizado nunca tem query string. |
| Todo envio responde `failed` | A chave está vazia. A variável precisa se chamar `NUXT_IKIFORM_SECRET_KEY` para preencher `runtimeConfig.ikiformSecretKey`. |
| `422` com `unknown_field` para `role` ou `ref` | A definição de campos não os lista. Adicione os dois, como na definição acima. |
| `402 plan_required` | O workspace não tem plano e o envio veio de um site publicado. Ele fica retido, não perdido. Escolha um plano ou compre um pacote em **Settings › Plan and usage** e os envios retidos chegam. |

## Perguntas

### Um formulário Nuxt funciona sem JavaScript?

Sim. Dê ao formulário um method e um action que apontem para uma server route. O readBody do Nitro interpreta o envio urlencoded, e a rota pode respondê-lo com um redirecionamento, enquanto os envios por $fetch da página hidratada recebem JSON.

### Posso pré-renderizar uma página Nuxt que tem um formulário de lista de espera?

Sim. Pré-renderize a página com routeRules e mantenha a server route do formulário ativa, depois faça o build com nuxt build. Leia valores da query, como ref, depois que a página monta.

### Onde guardo a chave secreta no Nuxt?

Numa chave privada do runtimeConfig, preenchida a partir de uma variável de ambiente NUXT_ no host. Chaves privadas só podem ser lidas no servidor, via useRuntimeConfig.

### O Ikiform é gratuito?

Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.

## Mais guias

- [Lista de espera para um novo SaaS](https://ikiform.com/pt-br/guides/waitlist.md): Monte uma lista de espera pré-lançamento na sua própria landing page: o formulário, proteção contra spam, duplicados, e-mails de confirmação e uma exportação limpa no dia do lançamento.
- [Lista de espera com Next.js](https://ikiform.com/pt-br/guides/waitlist/nextjs.md): Uma lista de espera no App Router do Next.js: uma server action que encaminha as inscrições com chave secreta, useActionState, agradecimento otimista e limite por visitante.
- [Lista de espera com SvelteKit](https://ikiform.com/pt-br/guides/waitlist/sveltekit.md): Uma lista de espera em SvelteKit 3: um hook guarda o primeiro ?ref= num cookie, uma form action nomeada encaminha a inscrição e quem volta vê que já se inscreveu.
- [Inscrição para beta com triagem](https://ikiform.com/pt-br/guides/waitlist/beta-signup.md): Um formulário de candidatura ao beta com perguntas de triagem que o servidor exige, e um script que pontua os candidatos e escolhe cada turma da lista.
- [Lista para lançamento no Product Hunt](https://ikiform.com/pt-br/guides/waitlist/product-hunt.md): Colete uma lista semanas antes do Product Hunt, marque cada inscrição pelo canal e envie o e-mail do dia do lançamento em duas ondas, no ritmo da virada do horário do Pacífico.
- [Lista de espera para app mobile](https://ikiform.com/pt-br/guides/waitlist/mobile-app.md): Uma lista de espera pré-lançamento para um app iOS ou Android que pergunta qual celular a pessoa usa, manda testadores de iPhone ao TestFlight e preenche um teste fechado do Play.
