# Crie um formulário de inscrição em newsletter no SvelteKit

> Um formulário de newsletter no layout do SvelteKit 3: uma form action com consentimento e limite por IP, use:enhance que fica na página e uma rota de webhook para o Kit.

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

Coloque a form action numa página `/newsletter` e envie para ela a partir de um formulário no root layout. `use:enhance` com `update({ navigate: false })` aplica o resultado à página em que o leitor está, então o rodapé o mostra sem sair do artigo, e sem JavaScript o mesmo envio cai em `/newsletter`. A action confere o consentimento, limita cada IP com `getClientAddress()`, encaminha a inscrição ao seu backend de formulários, e uma rota `+server.ts` repassa as inscrições verificadas ao Kit.

A [aba SvelteKit do guia principal](https://ikiform.com/pt-br/guides/newsletter-signup.md#form) envia de um rodapé para uma action em `/newsletter` e trata o resultado manualmente. Esta página faz do jeito do SvelteKit 3: variáveis de ambiente explícitas, `update({ navigate: false })` para que o framework aplique o resultado, um limite por IP e uma rota de webhook para o Kit. A configuração do produto está no [guia do SvelteKit na documentação](https://ikiform.com/pt-br/docs/guides/sveltekit.md).

## O que um formulário no layout muda no SvelteKit

Form actions só existem num `+page.server.ts`, nunca num layout, então um formulário no rodapé envia à action de outra página. O SvelteKit 3 fez os formulários aprimorados se comportarem como os nativos aqui: um formulário cuja action está em outra página navega até essa página. Widgets de newsletter são o caso que a documentação cita para desativar isso, com `update({ navigate: false })`. O resultado então é aplicado à página atual como `page.form`, que o rodapé lê.

Diagrama: Como uma inscrição no rodapé do SvelteKit volta para a página em que o leitor está

1. O leitor envia o formulário do rodapé. use:enhance o manda em segundo plano para a action subscribe em /newsletter.
2. A action confere o checkbox de consentimento e depois conta as tentativas recentes do IP do leitor.
3. Acima do limite, ela devolve fail(429) e nada é enviado.
4. Caso contrário, ela envia a inscrição ao Ikiform com a chave secreta e o Origin do navegador.
5. update com navigate false aplica o resultado à página atual como page.form, e o rodapé o mostra. Sem JavaScript, o navegador vai para /newsletter, que renderiza o mesmo componente.

Só o formulário cujo source corresponde ao resultado reage, então um rodapé e um formulário da página não mudam os dois.

## Configuração do projeto

`Árvore do projeto`:

```text
src/
  env.ts
  lib/
    NewsletterForm.svelte
    server/limit.ts
  routes/
    +layout.svelte
    newsletter/
      +page.server.ts
      +page.svelte
    api/ikiform/+server.ts
.env
```

O SvelteKit 3 descontinua os módulos `$env/*` em favor de variáveis explícitas. Declare-as em `src/env.ts`; elas são privadas e lidas em runtime por padrão, e `$app/env/private` se recusa a ser importado em código do navegador.

`src/env.ts`:

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

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

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567
```

Crie a chave do Ikiform em **API keys** com o escopo `submissions:write` e cole a [definição de campos do pilar](https://ikiform.com/pt-br/guides/newsletter-signup.md#fields) na aba **Fields** do formulário.

## Construa

### Um limite por IP

Uma pessoa se inscreve uma vez. Três tentativas em dez minutos deixam margem para um erro de digitação e barram um script. `getClientAddress()` devolve o IP que o seu adapter determinou, então o limitador não precisa saber qual header o seu host usa.

`src/lib/server/limit.ts`:

```ts
const WINDOW_MS = 10 * 60_000;
const LIMIT = 3;
const recent = new Map<string, number[]>();

export function allow(address: string, now = Date.now()) {
  const hits = (recent.get(address) ?? []).filter((time) => now - time < WINDOW_MS);
  if (hits.length >= LIMIT) return false;
  if (recent.size > 10_000) recent.clear();
  recent.set(address, [...hits, now]);
  return true;
}
```

O contador fica na memória de um único servidor. Em hosts serverless, cada instância conta por conta própria, então trate-o como um quebra-molas e mova-o para um armazenamento compartilhado quando o tráfego crescer. Arquivos num diretório `server` não podem ser importados por código do navegador. O SvelteKit 3 chega a `src/lib` pelo alias `#lib` que um projeto novo declara em `imports` no `package.json`; o antigo alias `$lib` não existe mais.

### A action

`src/routes/newsletter/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { allow } from "#lib/server/limit.js";
import type { Actions } from "./$types";

export const prerender = false;

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export const actions = {
  subscribe: async ({ request, fetch, getClientAddress, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    const picked = String(form.get("source") ?? "");
    const source = PLACEMENTS.includes(picked) ? picked : "footer";
    const refuse = (status: number, field: string, message: string) =>
      fail(status, { newsletter: { ok: false, source, field, message, email } });

    if (form.get("consent") !== "true") return refuse(422, "consent", "Tick the box so we know you want the emails.");
    if (!allow(getClientAddress())) return refuse(429, "form", "Too many tries from this connection. Wait a few minutes.");

    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,
        first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
        consent: true,
        source,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
    }).catch(() => null);

    const result = await response?.json().catch(() => null);
    if (result?.ok) return { newsletter: { ok: true, source } };
    const emailError = result?.error?.fields?.email?.[0];
    if (emailError) return refuse(422, "email", emailError);
    return refuse(response?.status ?? 502, "form", result?.error?.message ?? "We couldn't reach the list. Try again.");
  },
} satisfies Actions;
```

- O resultado vem embrulhado em `newsletter`, então nunca colide com o resultado de outro formulário em `page.form`.
- `fail()` mantém o e-mail digitado. O SvelteKit 3 também envia o status do `fail()` para formulários aprimorados, então a aba de rede mostra `422` ou `429`.
- A linha `Origin` repassa a origem do navegador, porque o Ikiform reconhece os testes gratuitos em `localhost` por esse header e o `fetch` de um servidor não envia nenhum.
- `prerender = false` importa se o seu root layout pré-renderiza tudo: páginas com actions não podem ser pré-renderizadas.

> 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 componente do formulário

`src/lib/NewsletterForm.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import { page } from "$app/state";

  type Outcome = { ok: boolean; source: string; field?: string; message?: string; email?: string };

  let { source = "footer" }: { source?: "footer" | "post" | "newsletter-page" } = $props();
  let pending = $state(false);
  const outcome = $derived.by(() => {
    const value = page.form?.newsletter as Outcome | undefined;
    return value?.source === source ? value : undefined;
  });
</script>

{#if outcome?.ok}
  <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
  <form
    method="POST"
    action="/newsletter?/subscribe"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ navigate: false, refreshAll: false, reset: false });
        pending = false;
      };
    }}
  >
    <label>
      Email
      <input name="email" type="email" required autocomplete="email" value={outcome?.email ?? ""} aria-invalid={outcome?.field === "email"} />
    </label>
    <label>
      First name (optional)
      <input name="first_name" maxlength="80" autocomplete="given-name" />
    </label>
    <label>
      <input name="consent" type="checkbox" value="true" required aria-invalid={outcome?.field === "consent"} />
      Email me the newsletter. I can unsubscribe at any time.
    </label>
    <input type="hidden" name="source" value={source} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if outcome?.message}
      <p role="alert">{outcome.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
  </form>
{/if}
```

- `navigate: false` mantém o leitor no artigo. `refreshAll: false` evita rodar de novo todas as funções `load` depois de uma inscrição bem-sucedida, que não muda nada do que elas carregam.
- `reset: false` mantém o e-mail digitado quando há erro. Em caso de sucesso o formulário é substituído de qualquer jeito.
- Com o JavaScript desligado, o envio vai para `/newsletter`, que renderiza esse mesmo componente, e `page.form` leva o resultado até lá.

### A página /newsletter

`src/routes/newsletter/+page.svelte`:

```svelte
<script lang="ts">
  import NewsletterForm from "#lib/NewsletterForm.svelte";
</script>

<svelte:head>
  <title>Newsletter</title>
</svelte:head>

<h1>The monthly newsletter</h1>
<p>What we shipped, what broke and what we learned. One email a month.</p>
<NewsletterForm source="newsletter-page" />
```

Renderize `<NewsletterForm />` em `+layout.svelte` para o rodapé e passe `source="post"` no fim dos artigos.

## Encaminhe assinantes ao Kit

Esta rota `+server.ts` recebe o webhook assinado do Ikiform, cria o assinante no Kit como inativo e o adiciona a um formulário do Kit, cujo e-mail de incentivo é a confirmação do Kit; teste uma vez com um endereço seu. Ela lê o corpo como texto primeiro porque a assinatura cobre os bytes exatos, e devolve objetos `Response` simples, já que o SvelteKit 3 descontinua os helpers `json()` e `text()`. O [guia do Kit](https://ikiform.com/pt-br/guides/newsletter-signup/kit.md) explica cada resposta do Kit e adiciona tags.

`src/routes/api/ikiform/+server.ts`:

```ts
import { IKIFORM_WEBHOOK_SECRET, KIT_API_KEY, KIT_FORM_ID } from "$app/env/private";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { RequestHandler } from "./$types";

export const POST: RequestHandler = async ({ request, fetch }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const kit = (path: string, body: Record<string, unknown>) =>
    fetch(`https://api.kit.com/v4${path}`, {
      method: "POST",
      headers: { "X-Kit-Api-Key": KIT_API_KEY, "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify(body),
    });

  const created = await kit("/subscribers", {
    email_address: email,
    first_name: typeof first_name === "string" ? first_name : null,
    state: "inactive",
  });
  const added = created.ok ? await kit(`/forms/${KIT_FORM_ID}/subscribers`, { email_address: email }) : created;

  if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

A proteção CSRF do SvelteKit está sempre ligada na versão 3, mas só rejeita envios de formulário cross-origin e posts cross-origin sem `Content-Type`. O Ikiform envia `application/json`, então o webhook passa. Adicione a URL em **Webhooks** e copie o segredo `whsec_`.

## Honeypot e a verificação humana

O input `_gotcha` é repassado, e o Ikiform armazena um preenchido como spam, com resposta de sucesso e sem webhook, então o Kit nunca o vê. A chave secreta pula a verificação Turnstile do Ikiform; o limite por IP acima cobre o loop que um script rodaria. Para bots que usam muitos IPs, verifique um token do Turnstile na action, como mostra o [guia do Turnstile](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md).

## Deploy

| Host | O que fazer |
| --- | --- |
| Vercel | `adapter-vercel` roda as actions como funções. O SvelteKit 3 remove o edge runtime ali, então use o padrão. Defina as variáveis em **Environment Variables**. |
| Netlify | `adapter-netlify` segue a Frameworks API da Netlify, e o diretório de publicação agora é uma opção do adapter. Defina as variáveis nas configurações de ambiente do site. |
| Cloudflare | `adapter-cloudflare` faz o build para Workers e precisa do Wrangler 4.118 ou mais recente, sua peer dependency. A documentação dele recomenda `$app/env` para variáveis. Use um domínio personalizado para o webhook; o Ikiform recusa `workers.dev` e `pages.dev`. |

## Teste em localhost de graça

Um workspace sem plano aceita 50 envios de teste por mês vindos de `localhost`, e o `Origin` repassado faz cada inscrição do servidor de desenvolvimento contar como um deles. O limitador conta você também, então reinicie o servidor de desenvolvimento se bater nele durante os testes. O Ikiform não entrega webhooks em `localhost`; exponha o servidor de desenvolvimento por um túnel com URL HTTPS para testar a rota.

## Confira se funciona

1. Rode `vite dev`, abra qualquer página e inscreva-se pelo rodapé. A frase aparece no rodapé e a URL não muda.
2. Desative o JavaScript e inscreva-se de novo. Você cai em `/newsletter` com a mesma frase.
3. Envie quatro vezes em dez minutos. O quarto envio mostra a mensagem de limite, e a aba de rede mostra `429`.
4. Abra **Submissions** no painel. O novo envio está lá com todos os campos que você enviou.
5. Se você adicionou um webhook, abra o endpoint em **Webhooks**. A entrega mostra `succeeded` com a resposta do seu servidor.
6. Abra o Kit. O assinante está lá como inativo, e o e-mail de confirmação do Kit está na sua caixa de entrada.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| Enviar o formulário do rodapé pula para `/newsletter` mesmo com JavaScript | O callback do `use:enhance` chama `update()` sem `navigate: false`. No SvelteKit 3, actions em outra página navegam por padrão. |
| O build falha porque `/newsletter` não pode ser pré-renderizada | Um layout define `prerender = true`. Páginas com actions precisam de servidor; defina `prerender = false` no arquivo de servidor da página. |
| Tanto o rodapé quanto o formulário da página mostram o resultado | Os dois leem o mesmo `page.form`. Compare o `source` dele com o do componente, como o exemplo faz. |
| `402 plan_required` em testes locais | O envio não tinha origem `localhost`. Confira a linha `Origin` e abra o site em `localhost`. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro **Spam** para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |

## Perguntas

### Como coloco uma form action do SvelteKit no layout?

Não dá para definir uma ali; actions pertencem a um arquivo +page.server. Coloque a action numa página como /newsletter e envie para ela a partir do layout com action="/newsletter?/subscribe". Use use:enhance com update({ navigate: false }) para ficar na página atual.

### Um site SvelteKit pré-renderizado pode ter um formulário de newsletter?

Sim. Pré-renderize os artigos e deixe só a página com a action, e a rota de webhook, para o servidor. Um site sem servidor nenhum pode, em vez disso, enviar do navegador direto ao Ikiform.

### Por que o assinante chega ao Kit por um webhook?

Porque só o webhook sabe quem é real. A form action recebe a mesma resposta de sucesso para um bot e para um leitor, enquanto o webhook só dispara para inscrições que passam pela checagem de spam do Ikiform, então o Kit nunca vê os bots.

### 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 inscrição em newsletter](https://ikiform.com/pt-br/guides/newsletter-signup.md): Crie um formulário de newsletter no seu próprio markup, registre o consentimento, barre inscrições de bots e passe cada assinante para Kit, MailerLite, beehiiv ou qualquer ferramenta.
- [Kit (ConvertKit)](https://ikiform.com/pt-br/guides/newsletter-signup/kit.md): Conecte seu próprio formulário de newsletter ao Kit com um relay que cria o assinante, o adiciona a um formulário do Kit para o incentive email e marca a posição com uma tag.
- [Double opt-in](https://ikiform.com/pt-br/guides/newsletter-signup/double-opt-in.md): O Ikiform não envia e-mails de confirmação, então o double opt-in roda na sua ferramenta de e-mail: um relay adiciona cada inscrição como não confirmada e a ferramenta envia o link.
- [Formulário de newsletter no Nuxt](https://ikiform.com/pt-br/guides/newsletter-signup/nuxt.md): Um formulário de newsletter no Nuxt que funciona com ou sem JavaScript: uma server route com verificação Turnstile envia ao Ikiform, e uma rota Nitro alimenta o beehiiv.
- [Site estático](https://ikiform.com/pt-br/guides/newsletter-signup/static-site.md): Adicione um formulário de newsletter a um site estático em Hugo, Jekyll, Eleventy ou Astro, mantenha a chave de API da ferramenta de e-mail fora da página e confirme na ferramenta.
- [MailerLite](https://ikiform.com/pt-br/guides/newsletter-signup/mailerlite.md): Conecte seu próprio formulário de inscrição ao MailerLite: um relay adiciona novos leitores como não confirmados para o double opt-in e nunca pede a um assinante que confirme duas vezes.
