Lista de espera para um novo SaaS7 min de leitura
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.
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 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.
- A landing page é HTML estático. Depois que ela monta, o componente copia ?ref= ou ?utm_source= para o valor de origem dele.
- O formulário envia para a server route: como JSON via $fetch depois que o Vue hidratou, ou urlencoded como envio simples antes disso.
- A rota recusa envios cross-site, valida o corpo com Zod e encaminha a inscrição ao Ikiform com a chave secreta.
- Um envio JSON recebe { joined: true }, ou um erro cuja mensagem o componente mostra abaixo do formulário.
- 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.
Configure o projeto
Crie o formulário e a chave secreta como no guia de Nuxt da documentação. Os arquivos desta página, no layout app/ do 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 },
},
});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.
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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.tslimita a rota aPOST. Qualquer outro método recebe405. Sec-Fetch-Siteésame-originpara as suas próprias páginas ecross-sitepara 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 umroleourefinvá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
Origindo navegador é repassado porque o Ikiform conta os envios de teste gratuitos delocalhostpor ele, e ofetchda 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
<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
methodeaction. Antes da hidratação, o navegador o envia urlencoded; depois, o@submit.prevento entrega ao$fetch, que envia JSON. - O
onMountedlê 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$fetchdeixa fora do corpo JSON. - O erro do
$fetchtraz a mensagem da rota emdata.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
- Rode
nuxt dev, abrahttp://localhost:3000/?ref=test, escolha uma função e inscreva-se. A frase de agradecimento substitui o formulário. - Abra Submissions no painel. A inscrição tem
refdefinido comoteste a função que você escolheu. - Desligue o JavaScript e inscreva-se sem função. Você cai em
/thanks, e o envio não temrole. - 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. - 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. - Envios do
nuxt devtrazem a origemlocalhost, 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 delocalhost. 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.
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
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.
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.
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.
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.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.