# 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.

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

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](https://ikiform.com/pt-br/guides/waitlist.md#form) 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(...)`.

Diagrama: O primeiro ?ref= de um visitante do SvelteKit o acompanha até a inscrição

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](https://ikiform.com/pt-br/docs/guides/sveltekit.md). Esta página usa o SvelteKit 3. Os arquivos:

`Projeto`:

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

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

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

`.env`:

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

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

`Campos`:

```json
{
  "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`:

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

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

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

| 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

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](https://ikiform.com/pt-br/pricing).

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

### Como rastreio origens UTM num formulário SvelteKit?

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.

### Uma página SvelteKit com form action pode ser pré-renderizada?

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.

### O cookie waitlist_joined impede inscrições duplicadas?

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.

### 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 Astro](https://ikiform.com/pt-br/guides/waitlist/astro.md): Uma lista de espera em Astro numa landing page estática: uma Action encaminha cada inscrição, um script a chama e uma página sob demanda pega os envios sem JavaScript.
- [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.
- [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 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.
