# Crie um formulário de leads com qualificação no SvelteKit

> Um formulário de leads em SvelteKit: o hook handle registra as UTMs de primeiro toque, uma form action valida com Valibot e redirect() manda leads qualificados para uma agenda.

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

Registre as tags UTM e o referrer no hook `handle` no primeiro GET, num cookie httpOnly. Valide o lead numa form action com Valibot, devolva `fail()` com os erros e os valores quando ele não passar, e encaminhe os leads válidos com a atribuição para o seu backend de formulários. Depois chame `redirect(303, url, { external: true })` para mandar leads qualificados ao seu agendador; `use:enhance` o segue até o outro site.

A [aba SvelteKit do guia principal](https://ikiform.com/pt-br/guides/lead-capture.md#form) encaminha um lead a partir de uma form action e mostra uma frase de agradecimento. Esta página monta a versão que uma equipe de vendas pede: tamanho da empresa, orçamento e prazo validados no servidor, a campanha por trás de cada lead e uma passagem para a sua agenda quando o lead se encaixa.

## O que muda no SvelteKit

Form actions são o jeito nativo do SvelteKit de tratar um envio, e elas degradam bem: sem JavaScript o navegador envia e segue os redirecionamentos sozinho, e com `use:enhance` a mesma action roda via `fetch`. Um resultado de redirect para outra origem cai num carregamento de página completo, então a passagem para o Cal.com não precisa de código extra no cliente.

O SvelteKit também dá um hook, `handle`, que envolve toda requisição ao servidor. É o lugar certo para registrar o primeiro toque. O SvelteKit 3 mudou duas coisas que este formulário usa: um redirect para outro site precisa de `{ external: true }`, e os segredos são declarados em `src/env.ts` e lidos de `$app/env/private` em tempo de execução.

Diagrama: O que o SvelteKit faz com cada tipo de resultado de action

1. O hook handle vê cada GET. Sem cookie lead_source ainda e com uma tag UTM ou um referrer externo, ele define um.
2. O SvelteKit resolve a requisição e renderiza a página normalmente.
3. No envio, use:enhance manda o formulário com fetch e define o estado de pendente.
4. A action valida com Valibot, lê o cookie e encaminha o lead com o Origin do visitante.
5. Um redirect para o Cal.com não pode ser uma navegação no cliente, então o SvelteKit o carrega como página completa.
6. Um resultado fail atualiza a prop form; update com reset false mantém os inputs como foram digitados.

Sem JavaScript o navegador faz as mesmas duas coisas sozinho: segue o 303 ou renderiza a página com form.

## Configuração do projeto

Comece de um projeto SvelteKit 3, instale `valibot` e adicione o seu adapter. Coloque a chave secreta no `.env` como `IKIFORM_SECRET_KEY` e declare-a em `src/env.ts`. Ela é privada e lida em tempo de execução por padrão, e o SvelteKit se recusa a importar `$app/env/private` em código do cliente. A [página da documentação sobre SvelteKit](https://ikiform.com/pt-br/docs/guides/sveltekit.md) cobre a chave e o formulário.

`arquivos`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
```

`src/env.ts`:

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

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

Adicione os campos na aba **Fields** do formulário, para que o Ikiform repita as verificações e recuse campos que você não definiu.

| Id do campo | Tipo no Ikiform | Definido por |
| --- | --- | --- |
| `name`, `email`, `company` | Text, email, text | O visitante |
| `company_size` | Select, com as opções `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | O visitante |
| `budget` | Select, com as opções `under_10k`, `10k_50k`, `50k_plus`, `unsure` | O visitante |
| `timeline` | Select, com as opções `this_quarter`, `next_quarter`, `later`, `researching` | O visitante |
| `message` | Textarea, opcional | O visitante |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | Text, opcional | Seu servidor, a partir da primeira visita |
| `fit` | Select, com as opções `qualified`, `nurture` | Seu servidor, após a validação |

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

## O primeiro toque no hook handle

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export const handle: Handle = async ({ event, resolve }) => {
  if (event.request.method === "GET" && !event.cookies.get("lead_source")) {
    const referer = event.request.headers.get("referer") ?? "";
    const from = URL.canParse(referer) ? new URL(referer) : null;
    const external = from && from.host !== event.url.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, event.url.searchParams.get(tag)?.slice(0, 100) ?? "");

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", event.url.pathname.slice(0, 200));
      event.cookies.set("lead_source", source.toString(), {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 30,
      });
    }
  }
  return resolve(event);
};
```

- Só requisições GET contam, então o próprio POST da action nunca define o cookie.
- O SvelteKit 3 usa `/` como `path` padrão de um cookie. Defini-lo explicitamente mantém o exemplo funcionando no SvelteKit 2, onde `path` é obrigatório.
- Um referrer do seu próprio host é ignorado, então cliques internos não substituem a origem real.
- O hook não roda para páginas pré-renderizadas servidas como arquivos. Deixe as landing pages de campanha renderizadas sob demanda, senão as tags nunca chegam a ele.

## A form action

`src/routes/contact-sales/+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import * as v from "valibot";
import type { Actions } from "./$types";

export const prerender = false;

const Lead = v.object({
  name: v.pipe(v.string(), v.trim(), v.nonEmpty("Tell us who to reply to."), v.maxLength(120)),
  email: v.pipe(v.string(), v.trim(), v.email("Enter a work email we can reply to.")),
  company: v.pipe(v.string(), v.trim(), v.nonEmpty("Which company is this for?"), v.maxLength(200)),
  company_size: v.picklist(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], "Pick a company size."),
  budget: v.picklist(["under_10k", "10k_50k", "50k_plus", "unsure"], "Pick a budget range."),
  timeline: v.picklist(["this_quarter", "next_quarter", "later", "researching"], "Pick a timeline."),
  message: v.pipe(v.string(), v.trim(), v.maxLength(5000, "Keep it under 5,000 characters.")),
  _gotcha: v.string(),
});

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export const actions = {
  default: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const values = Object.fromEntries(Object.keys(Lead.entries).map((key) => [key, String(form.get(key) ?? "")]));
    const parsed = v.safeParse(Lead, values);
    if (!parsed.success) {
      const nested = v.flatten<typeof Lead>(parsed.issues).nested ?? {};
      const errors = Object.fromEntries(Object.entries(nested).map(([key, list]) => [key, list?.[0] ?? ""]));
      return fail(400, { errors, values });
    }

    const lead = parsed.output;
    const stored = new URLSearchParams(cookies.get("lead_source") ?? "");
    const qualified =
      lead.company_size !== "1_10" &&
      (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
      (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");

    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({
        ...lead,
        ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
        fit: qualified ? "qualified" : "nurture",
      }),
    });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      const fields: Record<string, string[]> = result?.error?.fields ?? {};
      const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
      if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
      return fail(response.status >= 400 ? response.status : 502, { errors, values });
    }

    if (!qualified || lead._gotcha) redirect(303, "/contact-sales/thanks");
    redirect(303, `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`, { external: true });
  },
} satisfies Actions;
```

- O `picklist` do Valibot rejeita um `select` vazio com a sua mensagem, e `flatten` dá uma lista de mensagens por campo.
- `fail()` devolve também os valores, porque a página não tem outra cópia deles depois de um envio nativo. O SvelteKit 3 manda o status do `fail` como status HTTP.
- O header `Origin` do navegador é repassado, porque um fetch no servidor não manda nenhum e o Ikiform detecta por ele os testes gratuitos em `localhost`.
- `redirect()` lança uma exceção, então nada depois dele roda. Desde o SvelteKit 2 você o chama sem `throw`, e desde o SvelteKit 3 um destino em outra origem precisa de `{ external: true }`, senão a action falha em vez de redirecionar.
- Páginas com actions não podem ser pré-renderizadas. `prerender = false` deixa isso explícito caso um layout pai ligue a pré-renderização.

## A página

`src/routes/contact-sales/+page.svelte`:

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

  let { form }: PageProps = $props();
  let pending = $state(false);
  const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
  const values = $derived<Record<string, string | undefined>>(form?.values ?? {});

  const selects = [
    { id: "company_size", label: "Company size", options: [["1_10", "1 to 10"], ["11_50", "11 to 50"], ["51_200", "51 to 200"], ["201_1000", "201 to 1,000"], ["1000_plus", "More than 1,000"]] },
    { id: "budget", label: "Budget", options: [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]] },
    { id: "timeline", label: "When do you want to start?", options: [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]] },
  ];
</script>

<form
  method="POST"
  use:enhance={() => {
    pending = true;
    return async ({ update }) => {
      await update({ reset: false });
      pending = false;
    };
  }}
>
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required value={values.name ?? ""} />
  {#if errors.name}<p role="alert">{errors.name}</p>{/if}

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required value={values.email ?? ""} />
  {#if errors.email}<p role="alert">{errors.email}</p>{/if}

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required value={values.company ?? ""} />
  {#if errors.company}<p role="alert">{errors.company}</p>{/if}

  {#each selects as select (select.id)}
    <label for={select.id}>{select.label}</label>
    <select id={select.id} name={select.id} required value={values[select.id] ?? ""}>
      <option value="" disabled>Choose one</option>
      {#each select.options as [value, text] (value)}
        <option {value}>{text}</option>
      {/each}
    </select>
    {#if errors[select.id]}<p role="alert">{errors[select.id]}</p>{/if}
  {/each}

  <label for="message">Anything we should know?</label>
  <textarea id="message" name="message" rows="4">{values.message ?? ""}</textarea>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  {#if errors.form}<p role="alert">{errors.form}</p>{/if}
  <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
```

`use:enhance` executa o comportamento padrão via `update()`, mas `reset: false` mantém os inputs depois de uma falha. Sem JavaScript, os dados do `fail` da action voltam na prop `form` e os atributos `value` preenchem de novo cada campo.

### Honeypot e verificação humana

O honeypot está no schema, então chega ao Ikiform e um preenchido é armazenado como spam; a action o manda para a página de agradecimento. A verificação CSRF embutida do SvelteKit já rejeita envios de formulário de outras origens. A verificação humana do Ikiform é pulada com a chave secreta, então valide um token do Turnstile na action se precisar, como em [Turnstile num formulário](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md).

## Notas de deploy

| Host | O que saber |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel`, ou `adapter-auto`. Adicione `IKIFORM_SECRET_KEY` nas variáveis de ambiente do projeto. |
| Netlify | `@sveltejs/adapter-netlify`. Defina a chave nas configurações do site. |
| Cloudflare | `@sveltejs/adapter-cloudflare`. Guarde a chave com `wrangler secret put`. Ela só existe em tempo de execução, o que `$app/env/private` resolve, porque as variáveis declaradas são lidas quando o app inicia. |

## Confira se funciona

Envios do `vite dev` trazem a origem `localhost`. 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).

1. Rode `vite dev`, abra `/?utm_source=test&utm_medium=email` e confira o cookie `lead_source` nas ferramentas de desenvolvedor.
2. Em `/contact-sales`, escolha um tamanho de empresa, deixe **Budget** vazio e envie. O erro do orçamento aparece e as suas outras respostas continuam.
3. Envie como lead qualificado: 201 a 1.000 pessoas, $10k a $50k, este trimestre. A página carrega o Cal.com com nome e e-mail preenchidos.
4. Envie como **Later this year**. Você cai em `/contact-sales/thanks`.
5. Abra **Submissions** no painel e confira `utm_medium`, `landing_page` e `fit`.
6. Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e **Reply** vai para quem enviou quando o formulário tem um campo `email`.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| `403 Cross-site POST form submissions are forbidden` | A verificação CSRF do SvelteKit viu uma origem que não bate. Atrás de um proxy que não repassa o host, defina `paths.origin` nas opções de `sveltekit()` em `vite.config.ts`; o SvelteKit 3 removeu a variável `ORIGIN` do adapter-node. Para outro site seu, adicione-o a `csrf.trustedOrigins`. |
| O redirect para o Cal.com falha com um erro sobre URL externa | O SvelteKit 3 recusa redirects externos por padrão. Passe `{ external: true }`, ou uma allowlist como `{ external: ["https://cal.com"] }`, como terceiro argumento. |
| A página não vai para o Cal.com e o console mostra um erro | Um callback personalizado de `use:enhance` chamou `goto()` no redirect. `goto` rejeita URLs externas; chame `update()` ou `applyAction()` no lugar. |
| Os campos ficam em branco depois de um erro | `update()` foi chamado sem `reset: false`, ou os inputs não leem `form.values`. |
| `422 validation_failed` | O envio não corresponde à definição de campos do formulário. `error.fields` aponta cada campo e o motivo. Faça o `name` do input bater com o id do campo. |

## Perguntas

### Uma form action do SvelteKit pode redirecionar para uma URL externa?

Sim. Chame redirect(303, 'https://...', { external: true }) na action; o SvelteKit 3 recusa um destino externo sem essa opção. Sem JavaScript o navegador o segue, e com use:enhance o SvelteKit cai num carregamento de página completo porque a URL não faz parte do seu app.

### Como leio a chave secreta no SvelteKit 3?

Declare-a em src/env.ts com defineEnvVars e importe-a de $app/env/private. Ela é lida em tempo de execução, a menos que você defina static: true, então segredos que só existem em tempo de execução, como os secrets do Cloudflare Workers, funcionam. Os módulos $env ainda funcionam, mas estão obsoletos.

### Como mantenho os valores do formulário depois de uma action do SvelteKit que falhou?

Devolva-os em fail(400, { errors, values }) e ligue o value de cada input a form.values. Com use:enhance, chame update({ reset: false }) para o formulário não ser limpo.

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

- [Formulário de captação de leads](https://ikiform.com/pt-br/guides/lead-capture.md): Um formulário de leads no seu próprio site que envia e-mail com reply-to, avisa o chat da equipe e alimenta seu CRM, para você responder enquanto o lead ainda está lá.
- [Formulário de leads em Nuxt](https://ikiform.com/pt-br/guides/lead-capture/nuxt.md): Um formulário de leads em Nuxt: um server middleware registra as UTMs de primeiro toque, uma server route validada com Zod atende fetch e envios simples, e os bons leads agendam uma call.
- [Formulário de leads em Astro](https://ikiform.com/pt-br/guides/lead-capture/astro.md): Um formulário de leads em Astro feito com Actions: validação com Zod, UTMs de primeiro toque num site estático, uma página de contato sob demanda e redirecionamento ao seu agendador.
- [Formulário de pedido de demonstração](https://ikiform.com/pt-br/guides/lead-capture/demo-request.md): Um formulário de agendar demonstração que armazena o lead e depois manda o visitante para o Cal.com ou o Calendly com nome e e-mail já preenchidos.
- [Formulário de pedido de orçamento](https://ikiform.com/pt-br/guides/lead-capture/quote-request.md): Um formulário de pedido de orçamento que recebe briefings como links compartilhados em vez de uploads, coloca um prazo em cada pedido e mantém leads cheios de links fora do spam.
- [Formulário de pedido de consulta](https://ikiform.com/pt-br/guides/lead-capture/consultation-request.md): Um formulário de pedido de consulta para contadores, advogados, coaches e clínicas que pergunta como e quando ligar, e deixa os detalhes sensíveis para a conversa.
