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.
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:
| Recurso | O que ele faz aqui |
|---|---|
handle no hooks.server.ts | Roda antes de toda requisição renderizada no servidor, então consegue ver ?ref= em qualquer página em que o visitante chegue |
event.cookies | Guarda a primeira origem por 30 dias. A action a lê de volta, sem input oculto para adulterar |
Form actions com use:enhance | Enviam sem JavaScript e melhoram quando ele carrega, com um único trecho de código de servidor |
| A regra | Uma 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(...).
- Um visitante chega a um post de blog por um link com ?ref=news.
- O hook handle vê a tag, não encontra ainda um cookie waitlist_ref e define um por 30 dias.
- 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.
- A pessoa se inscreve. A action join lê o waitlist_ref e encaminha a inscrição ao Ikiform com ref definido como news.
- 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.
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:
src/
env.ts
hooks.server.ts
routes/
+page.server.ts
+page.svelte
blog/[slug]/+page.svelte
vite.config.ts
.envDeclare 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.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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_lengthdo Ikiform permite, para que um link forjado não consiga armazenar lixo. httpOnlymantém o cookie longe dos scripts da página.sameSite: "lax"ainda o envia no próprioPOSTdo formulário.- No SvelteKit 3, o
pathde um cookie é/por padrão, então ele chega a todas as rotas sem precisar defini-lo.
A action nomeada
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
Origindo navegador é repassado porque o Ikiform conta os envios de teste gratuitos delocalhostpor ele, e ofetchda 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 aofail(), entãopage.statusmostra400ou429.- O
loadlêwaitlist_joinedpara 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
<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 comformdefinido.- O
use:enhanceenvia comfetchdepois 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. pendingdesativa o botão até a action responder, o que impede um envio duplo num celular lento.data.joinedvem doloadeform.joinedvem 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.
| Host | Adapter | Observações |
|---|---|---|
| Vercel | @sveltejs/adapter-vercel | O SvelteKit 3 removeu o runtime edge aqui, então a action roda em funções Node |
| Netlify | @sveltejs/adapter-netlify | Defina a chave em Environment variables |
| Cloudflare | @sveltejs/adapter-cloudflare | Exige wrangler 4.118 ou posterior, a peer dependency do adapter. Defina a chave com wrangler secret put IKIFORM_SECRET_KEY |
Confira se funciona
- Rode
vite dev, abrahttp://localhost:5173/?ref=test, depois remova a query e recarregue. O cookiewaitlist_refcontinuatestnas ferramentas de desenvolvedor do navegador. - Inscreva-se. A frase de agradecimento substitui o formulário sem recarregar, e a inscrição em Submissions tem
refdefinido comotest. - Recarregue a página. Aparece a frase de agradecimento, não o formulário.
- Limpe os cookies, desligue o JavaScript e envie
a@b. A página volta com o erro e o endereço ainda no input. - 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
vite 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 |
|---|---|
| O build falha dizendo que uma página com actions não pode ser pré-renderizada | Um 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 direct | O 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 atualizar | O SvelteKit 3 descontinuou os módulos $env. Declare a chave em src/env.ts e importe-a de $app/env/private. |
422 com unknown_field | A definição de campos não lista ref. Adicione-o 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
Leia a query no hook handle na primeira requisição, guarde a tag num cookie httpOnly e leia o cookie na form action. A origem sobrevive à navegação e nunca aparece no formulário.
Não. Uma página com actions precisa de um servidor para responder ao POST dela, então o SvelteKit se recusa a pré-renderizá-la. Pré-renderize as outras páginas e mantenha a página do formulário renderizada no servidor.
Só naquele navegador. O Ikiform não remove duplicados por e-mail, então remova as repetições onde a lista fica ou na hora de exportá-la.
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.