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.
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ê.
- O leitor envia o formulário do rodapé. use:enhance o manda em segundo plano para a action subscribe em /newsletter.
- A action confere o checkbox de consentimento e depois conta as tentativas recentes do IP do leitor.
- Acima do limite, ela devolve fail(429) e nada é enviado.
- Caso contrário, ela envia a inscrição ao Ikiform com a chave secreta e o Origin do navegador.
- 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.
Configuração do projeto
src/
env.ts
lib/
NewsletterForm.svelte
server/limit.ts
routes/
+layout.svelte
newsletter/
+page.server.ts
+page.svelte
api/ikiform/+server.ts
.envO 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.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
IKIFORM_WEBHOOK_SECRET: {},
KIT_API_KEY: {},
KIT_FORM_ID: {},
});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=1234567Crie 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.
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
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 empage.form. fail()mantém o e-mail digitado. O SvelteKit 3 também envia o status dofail()para formulários aprimorados, então a aba de rede mostra422ou429.- A linha
Originrepassa a origem do navegador, porque o Ikiform reconhece os testes gratuitos emlocalhostpor esse header e ofetchde um servidor não envia nenhum. prerender = falseimporta se o seu root layout pré-renderiza tudo: páginas com actions não podem ser pré-renderizadas.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O componente do formulário
<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: falsemantém o leitor no artigo.refreshAll: falseevita rodar de novo todas as funçõesloaddepois de uma inscrição bem-sucedida, que não muda nada do que elas carregam.reset: falsemanté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, epage.formleva o resultado até lá.
A página /newsletter
<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.
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
| 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
- Rode
vite dev, abra qualquer página e inscreva-se pelo rodapé. A frase aparece no rodapé e a URL não muda. - Desative o JavaScript e inscreva-se de novo. Você cai em
/newslettercom a mesma frase. - Envie quatro vezes em dez minutos. O quarto envio mostra a mensagem de limite, e a aba de rede mostra
429. - Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Se você adicionou um webhook, abra o endpoint em Webhooks. A entrega mostra
succeededcom a resposta do seu servidor. - 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
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.
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.
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.
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.