Formulário de inscrição em newsletter

Formulário de inscrição em newsletter9 min de leitura

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.

Preet SutharFundador, Ikiform

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

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

Como uma inscrição no rodapé do SvelteKit volta para a página em que o leitor estáformulárionorodapé+layout.svelte,qualquerpágina?/subscribeactionem/newslettergetClientAddress()3tentativasacada10minutosIkiformchavesecreta,armazenainscriçãopage.formnapáginaatualfail(429)mensagem,nadaenviadouse:enhancepermitidonavigate: falseacima do limiteComo uma inscrição no rodapé do SvelteKit volta para a página em que o leitor estáformulário norodapé+layout.svelte,qualquer página?/subscribeaction em /newslettergetClientAddress()3 tentativas acada 10 minutosIkiformchave secreta,armazena inscriçãopage.formna página atualfail(429)mensagem,nada enviadouse:enhancepermitidonavigate: falseacima do limite
  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
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
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
  IKIFORM_WEBHOOK_SECRET: {},
  KIT_API_KEY: {},
  KIT_FORM_ID: {},
});
.env
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 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
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
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 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
<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
<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 explica cada resposta do Kit e adiciona tags.

src/routes/api/ikiform/+server.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.

Deploy

HostO que fazer
Verceladapter-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.
Netlifyadapter-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.
Cloudflareadapter-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

ProblemaO que fazer
Enviar o formulário do rodapé pula para /newsletter mesmo com JavaScriptO 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é-renderizadaUm 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 resultadoOs dois leem o mesmo page.form. Compare o source dele com o do componente, como o exemplo faz.
402 plan_required em testes locaisO 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 chegouEle 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.