Lista de espera para um novo SaaS

Lista de espera para um novo SaaS8 min de leitura

Monte uma lista de espera em SvelteKit

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.

Preet SutharFundador, Ikiform

Leia ?ref= no hooks.server.ts na primeira requisição do visitante e guarde-o num cookie, para que a inscrição seja creditada ao link que trouxe a pessoa mesmo depois que ela navegar pelo site. Uma form action nomeada lê o cookie, encaminha a inscrição ao seu backend de formulários com uma chave secreta e define um segundo cookie para que a página cumprimente um visitante que volta em vez de pedir de novo.

O guia principal tem uma aba de SvelteKit: uma action padrão e use:enhance, com ref fixo em homepage. Esta página adiciona o que um lançamento real precisa. Os visitantes chegam a um post de blog vindo de uma newsletter, leem um pouco e se inscrevem duas páginas depois. Você quer essa inscrição creditada à newsletter, não à página em que digitaram o e-mail.

O que muda no SvelteKit

Dois recursos do SvelteKit se encaixam bem aqui, e uma regra molda a configuração:

RecursoO que ele faz aqui
handle no hooks.server.tsRoda antes de toda requisição renderizada no servidor, então consegue ver ?ref= em qualquer página em que o visitante chegue
event.cookiesGuarda a primeira origem por 30 dias. A action a lê de volta, sem input oculto para adulterar
Form actions com use:enhanceEnviam sem JavaScript e melhoram quando ele carrega, com um único trecho de código de servidor
A regraUma página com actions não pode ser pré-renderizada, porque um servidor precisa responder ao POST dela

Um input oculto não dá conta disso. Ele só conhece a página em que o formulário está, e uma tag na URL some depois do primeiro clique. Um cookie definido no servidor a mantém em todas as páginas, e o visitante nunca a vê nem a edita.

Isto é atribuição first-touch: o primeiro link marcado vence. Uma visita posterior vinda de outro link não o sobrescreve. Se preferir creditar o último link, remova a verificação !event.cookies.get(...).

O primeiro ?ref= de um visitante do SvelteKit o acompanha até a inscriçãoPrimeiro diaInscrição/blog/launch?ref=newsprimeira visitahook handledefinewaitlist_ref=news/pricing, depois/sem query stringaction ?/joinlê waitlist_refIkiformref = newswaitlist_joineda próxima visita dizque você já estádentrorequisiçãonavegaJSONcookieO primeiro ?ref= de um visitante do SvelteKit o acompanha até a inscriçãoPrimeiro diaInscrição/blog/launch?ref=newsprimeira visitahook handledefine waitlist_ref=news/pricing, depois /sem query stringaction ?/joinlê waitlist_refIkiformref = newswaitlist_joineda próxima visita diz que você jáestá dentrorequisiçãonavegaJSONcookie
  1. Um visitante chega a um post de blog por um link com ?ref=news.
  2. O hook handle vê a tag, não encontra ainda um cookie waitlist_ref e define um por 30 dias.
  3. A pessoa lê a página de preços e volta para a página inicial. Nenhuma das URLs tem tag; o cookie ainda tem.
  4. A pessoa se inscreve. A action join lê o waitlist_ref e encaminha a inscrição ao Ikiform com ref definido como news.
  5. Em caso de sucesso, a action define waitlist_joined, então a página inicial mostra a frase de agradecimento na próxima visita em vez do formulário.
A origem fica num cookie httpOnly, não no formulário, então sobrevive à navegação e não pode ser editada na página.

Configure o projeto

Crie o formulário e uma chave secreta como no guia de SvelteKit da documentação. Esta página usa o SvelteKit 3. Os arquivos:

Projeto
src/
  env.ts
  hooks.server.ts
  routes/
    +page.server.ts
    +page.svelte
    blog/[slug]/+page.svelte
vite.config.ts
.env

Declare o segredo

O SvelteKit 3 descontinuou os módulos $env/... em favor de variáveis declaradas. Declare a chave em src/env.ts; as variáveis são privadas, a menos que você as marque como públicas, e $app/env/private não pode ser importado em código do navegador.

src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
.env
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.

A action envia email e ref. Se o formulário tiver uma definição de campos na aba Fields, liste os dois:

Campos
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}

Construa

Capture a origem

src/hooks.server.ts
import type { Handle } from "@sveltejs/kit/hooks";

const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;

export const handle: Handle = async ({ event, resolve }) => {
  const query = event.url.searchParams;
  const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();

  if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
    event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
  }
  return resolve(event);
};
  • A tag é passada para minúsculas e precisa bater com o mesmo padrão que o max_length do Ikiform permite, para que um link forjado não consiga armazenar lixo.
  • httpOnly mantém o cookie longe dos scripts da página. sameSite: "lax" ainda o envia no próprio POST do formulário.
  • No SvelteKit 3, o path de um cookie é / por padrão, então ele chega a todas as rotas sem precisar defini-lo.

A action nomeada

src/routes/+page.server.ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;

export const load: PageServerLoad = ({ cookies }) => ({
  joined: cookies.get("waitlist_joined") === "yes",
});

export const actions = {
  join: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify({
        email,
        ref: cookies.get("waitlist_ref") ?? "direct",
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    }).catch(() => null);

    if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(response.status === 429 ? 429 : 400, {
        email,
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    }

    cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
    return { joined: true };
  },
} satisfies Actions;
  • Uma action nomeada, join, deixa espaço para outras nesta página depois. O SvelteKit não permite uma action padrão junto com actions nomeadas.
  • 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 action não envia nenhum.
  • fail() devolve o e-mail digitado e uma mensagem. No SvelteKit 3, um envio com enhance usa o status que você passa ao fail(), então page.status mostra 400 ou 429.
  • O load lê waitlist_joined para que um visitante que volta veja que já está inscrito. É uma cortesia para um navegador, não uma deduplicação; o Ikiform ainda armazena uma segunda inscrição vinda de outro dispositivo.

A página

src/routes/+page.svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { data, form }: PageProps = $props();
  let pending = $state(false);
</script>

<h1>Acme is almost here</h1>

{#if data.joined || form?.joined}
  <p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
  <form
    method="POST"
    action="?/join"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ reset: false });
        pending = false;
      };
    }}
  >
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{form.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
  </form>
{/if}
  • action="?/join" nomeia a action. Sem JavaScript, o navegador envia para lá e o SvelteKit renderiza a página com form definido.
  • O use:enhance envia com fetch depois que a página é hidratada. update({ reset: false }) mantém o e-mail no input depois de um erro, para o visitante corrigir um erro de digitação em vez de digitar tudo de novo.
  • pending desativa o botão até a action responder, o que impede um envio duplo num celular lento.
  • data.joined vem do load e form.joined vem da action. Conferir os dois cobre um visitante que volta e uma inscrição nova com uma única condição.

O honeypot chega intacto ao Ikiform, 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.

Pré-renderização e deploy

A página inicial tem uma action, então precisa ser renderizada no servidor. Posts de blog e outros conteúdos podem ser pré-renderizados com export const prerender = true, mas o handle não roda quando um visitante abre um arquivo pré-renderizado; ele rodou uma vez, no build. Um link marcado que leva direto a uma página pré-renderizada não define cookie nenhum. Aponte os links de campanha para uma página renderizada no servidor, como a página inicial.

HostAdapterObservações
Vercel@sveltejs/adapter-vercelO SvelteKit 3 removeu o runtime edge aqui, então a action roda em funções Node
Netlify@sveltejs/adapter-netlifyDefina a chave em Environment variables
Cloudflare@sveltejs/adapter-cloudflareExige wrangler 4.118 ou posterior, a peer dependency do adapter. Defina a chave com wrangler secret put IKIFORM_SECRET_KEY

Confira se funciona

  1. Rode vite dev, abra http://localhost:5173/?ref=test, depois remova a query e recarregue. O cookie waitlist_ref continua test nas ferramentas de desenvolvedor do navegador.
  2. Inscreva-se. A frase de agradecimento substitui o formulário sem recarregar, e a inscrição em Submissions tem ref definido como test.
  3. Recarregue a página. Aparece a frase de agradecimento, não o formulário.
  4. Limpe os cookies, desligue o JavaScript e envie a@b. A página volta com o erro e o endereço ainda no input.
  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 vite 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
O build falha dizendo que uma página com actions não pode ser pré-renderizadaUm layout define prerender = true para todas as rotas. Defina export const prerender = false no +page.server.ts da página da lista de espera.
ref é sempre directO link marcado apontava para uma página pré-renderizada, então o handle nunca o viu, ou uma visita anterior já tinha definido o cookie. Confira o cookie nas ferramentas de desenvolvedor.
Avisos sobre $env/static/private depois de atualizarO SvelteKit 3 descontinuou os módulos $env. Declare a chave em src/env.ts e importe-a de $app/env/private.
422 com unknown_fieldA definição de campos não lista ref. Adicione-o 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.