Lista de espera para um novo SaaS

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.

Preet SutharFundador, Ikiform

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:

DetalheEfeito na lista de espera
routeRules com prerender: trueA landing page é gerada uma vez como HTML. A server route continua rodando a cada requisição
readBody também interpreta corpos urlencodedUm <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.

Uma rota Nitro responde ao $fetch com JSON e a um envio simples com um redirecionamento/pré-renderizadaonMountedlê?ref=server/api/waitlist.post.tsverificaçãosame-site,ZodIkiformchavesecreta,armazena{joined:true}$fetch,mesmapágina303/thanksou/?problem=emailpáginapré-renderizadamostraoresultadoPOSTJSONcontent-type jsonurlencodedUma rota Nitro responde ao $fetch com JSON e a um envio simples com um redirecionamento/ pré-renderizadaonMounted lê ?ref=server/api/waitlist.post.tsverificação same-site,ZodIkiformchave secreta,armazena{ joined: true }$fetch, mesma página303/thanksou/?problem=emailpáginapré-renderizadamostra oresultadoPOSTJSONcontent-type jsonurlencoded
  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. Os arquivos desta página, no layout app/ do Nuxt 4:

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

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
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 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
{
  "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
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
<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.

HostConfiguraçãoO segredo
VercelZero configuração. A Vercel detecta o Nitro na importaçãoNUXT_IKIFORM_SECRET_KEY em Environment Variables
NetlifyZero configuração. O Nuxt detecta o build da NetlifyNUXT_IKIFORM_SECRET_KEY em Environment variables
Cloudflare PagesZero configuração com a integração com Git. Para um upload direto, faça o build com --preset=cloudflare_pagesNUXT_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.

Pegadinhas

ProblemaO que fazer
404 em /api/waitlist em produçãoO 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 refA query é lida durante a renderização. Leia-a no onMounted, como acima; o HTML pré-renderizado nunca tem query string.
Todo envio responde failedA chave está vazia. A variável precisa se chamar NUXT_IKIFORM_SECRET_KEY para preencher runtimeConfig.ikiformSecretKey.
422 com unknown_field para role ou refA definição de campos não os lista. Adicione os dois, como na definição acima.
402 plan_requiredO 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.