Formulário de inscrição em newsletter9 min de leitura
Crie um formulário de inscrição em newsletter no Next.js
Um formulário de newsletter no rodapé do Next.js: uma server action com checagem de consentimento e Turnstile, enviando ao Ikiform, e um route handler que adiciona leitores ao Kit.
Coloque um formulário client no rodapé do root layout e aponte-o para uma server action. A action confere o checkbox de consentimento e um token do Cloudflare Turnstile, e então encaminha a inscrição ao seu backend de formulários com uma chave secreta. Um route handler separado recebe o webhook assinado do backend e adiciona o leitor à sua ferramenta de e-mail, então spam nunca chega à sua lista.
A aba Next.js do guia principal tem uma server action e um componente de formulário. Esta página monta a versão completa para um site real: um formulário no rodapé de todas as páginas, uma verificação humana que continua funcionando quando a chave secreta pula a do Ikiform, e um route handler que repassa ao Kit as inscrições comprovadamente válidas. A configuração do produto e as chaves estão no guia do Next.js na documentação.
O que um formulário no rodapé muda no Next.js
Um formulário no rodapé fica no root layout, então é renderizado em todas as páginas, inclusive nas que o Next.js gera uma vez no deploy. Isso tem três consequências:
| Questão | O que acontece | O que esta implementação faz |
|---|---|---|
| Páginas estáticas | O formulário não pode ler cookies nem headers durante a renderização, senão toda página vira dinâmica | O formulário não lê nada na renderização. Só a action toca a requisição |
| A verificação humana | Um envio com chave secreta pula a verificação Turnstile do Ikiform | Roda a própria verificação Turnstile dentro da action |
| Quem pode chamar | Uma server action é um endpoint POST público, acessível sem o seu formulário | Valida o consentimento e o token antes de enviar qualquer coisa |
- O leitor preenche o formulário do rodapé. Ele envia à server action da página que o leitor estiver lendo.
- A action confere o checkbox de consentimento e depois valida o token do Turnstile com a Cloudflare.
- Se alguma checagem falhar, o formulário volta com a mensagem e o e-mail digitado. Nada é enviado.
- Caso contrário, a action envia a inscrição ao Ikiform com a chave secreta e o Origin do navegador.
- A resposta do Ikiform vira o action state, e o formulário é substituído pela frase de confira-sua-caixa-de-entrada.
- Em paralelo, o Ikiform envia um webhook assinado para cada inscrição que não é spam. O route handler o verifica e adiciona o leitor a um formulário do Kit, cujo e-mail de incentivo é a confirmação do Kit. Teste isso uma vez com um endereço seu; o guia do Kit explica por quê.
Configuração do projeto
app/
layout.tsx
newsletter/
actions.ts
newsletter-form.tsx
submit-button.tsx
page.tsx
api/ikiform/route.ts
lib/
turnstile.ts
.env.localCrie uma chave secreta em API keys com o escopo submissions:write. Crie um widget Turnstile no painel da Cloudflare para o seu domínio e para localhost. Só a site key pode levar o prefixo NEXT_PUBLIC_; os outros valores precisam ficar só no servidor.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567Cole a definição de campos do pilar na aba Fields do formulário, para que o Ikiform exija o checkbox consent obrigatório não importa o que chegue.
Construa
A verificação do Turnstile
O endpoint siteverify da Cloudflare recebe o segredo, o token e, opcionalmente, o IP do visitante. Um token vale por 300 segundos e só pode ser verificado uma vez.
import "server-only";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export async function passesTurnstile(token: string, visitor?: string) {
if (!token) return false;
const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
if (visitor) body.set("remoteip", visitor);
const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
return outcome?.success === true;
}A server action
"use server";
import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";
export interface SignupState {
status: "idle" | "subscribed" | "error";
field?: "email" | "consent" | "check";
message: string;
email: string;
firstName: string;
}
const PLACEMENTS = ["footer", "post", "newsletter-page"];
export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
const kept = { email, firstName };
if (formData.get("consent") !== "true") {
return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
}
const request = await headers();
const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
}
const source = String(formData.get("source") ?? "");
let response: Response;
try {
response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
Origin: request.get("origin") ?? "",
},
body: JSON.stringify({
email,
first_name: firstName || undefined,
consent: true,
source: PLACEMENTS.includes(source) ? source : "footer",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
}
const result = await response.json().catch(() => null);
if (result?.ok) {
return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
}
const emailError = result?.error?.fields?.email?.[0];
if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}- O consentimento é conferido primeiro porque não custa nada. A chamada ao Turnstile vem em segundo.
sourceé comparado com uma lista fixa. Um envio forjado não consegue encher seu relatório de posições com lixo.- 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. - Todo erro devolve o e-mail e o nome digitados. O React reseta o formulário depois de uma action, e
defaultValueos coloca de volta. - O honeypot é repassado intacto. O Ikiform armazena um preenchido como spam e responde com sucesso, então o bot não descobre nada.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O formulário e seu estado pendente
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}"use client";
import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";
declare global {
interface Window {
turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
}
}
const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };
export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
const [state, action] = useActionState(subscribe, initial);
const id = useId();
const box = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
function mountCheck() {
if (!box.current || !window.turnstile || widget.current) return;
widget.current = window.turnstile.render(box.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
});
}
useEffect(mountCheck, []);
useEffect(() => {
if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
}, [state]);
if (state.status === "subscribed") return <p role="status">{state.message}</p>;
return (
<form action={action}>
<Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
<label htmlFor={`${id}-email`}>Email</label>
<input
id={`${id}-email`}
name="email"
type="email"
required
autoComplete="email"
defaultValue={state.email}
aria-invalid={state.field === "email"}
/>
<label htmlFor={`${id}-name`}>First name (optional)</label>
<input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
<label>
<input name="consent" type="checkbox" value="true" required aria-invalid={state.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 />
<div ref={box} />
{state.status === "error" ? <p role="alert">{state.message}</p> : null}
<SubmitButton />
</form>
);
}useFormStatusprecisa ser lido num filho do<form>, e é por isso que o botão é um componente próprio.useIdmantém os ids dos inputs únicos quando o formulário do rodapé e um formulário da página/newsletteraparecem juntos na tela.- O script do Turnstile carrega com
?render=explicit.rendercoloca o widget dentro do formulário, que então envia um input ocultocf-turnstile-response. A renderização explícita também funciona para formulários montados depois de uma navegação no cliente. - Um token só pode ser verificado uma vez, então o widget é resetado depois de cada erro. Sem o reset, a segunda tentativa sempre falha.
Coloque no layout
import { NewsletterForm } from "./newsletter/newsletter-form";
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
{children}
<footer>
<p>One email a month about what we shipped. No tracking pixels.</p>
<NewsletterForm source="footer" />
</footer>
</body>
</html>
);
}Use source="post" no fim dos artigos e source="newsletter-page" numa página dedicada. LayoutProps é o tipo que o Next.js gera para layouts, ciente da rota.
Encaminhe assinantes ao Kit
O route handler abaixo recebe o webhook do Ikiform. Ele lê o corpo como texto primeiro, porque a assinatura cobre os bytes exatos. Adicione a URL dele em Webhooks, escolha o formulário da newsletter e copie o segredo whsec_. O guia do Kit cobre tags e o que cada resposta do Kit significa.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
function kit(path: string, body: Record<string, unknown>) {
return fetch(`https://api.kit.com/v4${path}`, {
method: "POST",
headers: {
"X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});
}
export async function POST(request: Request) {
let event;
try {
event = await verifyWebhook({
payload: await request.text(),
headers: request.headers,
secret: process.env.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 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/${process.env.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 });
}- Se você tem um
proxy.ts, antigomiddleware.ts, que redireciona visitantes sem sessão, deixe/api/ikiformfora domatcherdele. Senão toda entrega recebe o seu redirecionamento de login em vez do handler. - Com
trailingSlash: truenonext.config, o Next.js redireciona/api/ikiformpara/api/ikiform/. Registre a URL com a barra. - Um
503comRetry-Afterfaz o Ikiform esperar e tentar de novo, e um502coloca o status do Kit no log de entregas.
Renderização e progressive enhancement
Nada no formulário lê a requisição durante a renderização, então o layout não torna suas páginas estáticas dinâmicas. A action roda a cada requisição, seja qual for o modo de renderização da página. Como a action vai direto em action, o React renderiza um envio de formulário de verdade, e um leitor que envia antes de o JavaScript carregar ainda chega à action. O widget do Turnstile precisa de JavaScript, então com scripts desligados o leitor recebe a mensagem da verificação humana em vez de uma inscrição. Se essa troca não serve para seus leitores, tire a verificação e conte com o honeypot e o checkbox obrigatório.
Deploy
| Host | O que fazer |
|---|---|
| Vercel | Adicione as seis variáveis em Environment Variables do projeto. Aponte o webhook do Ikiform para o seu domínio de produção; deploys de preview podem ficar atrás do Deployment Protection da Vercel, que recusa o webhook. |
| Netlify | A Netlify faz o build do Next.js com o próprio adapter. Adicione as variáveis nas configurações de ambiente do site, com escopo de Functions, e faça o redeploy. |
| Cloudflare | Rode o Next.js em Workers via OpenNext. Defina as variáveis nas configurações do Worker e dê a ele um domínio personalizado: o Ikiform recusa URLs de webhook em workers.dev. |
Teste em localhost de graça
Um workspace sem plano aceita 50 envios de teste por mês vindos de localhost. Graças ao Origin repassado, next dev em http://localhost:3000 conta como um deles. Use as chaves de teste do Turnstile publicadas pela Cloudflare no .env.local para que a verificação sempre passe na sua máquina. Webhooks não alcançam localhost; para testar o route handler, exponha-o com um túnel que dê uma URL HTTPS, ou use Send test no painel depois do deploy.
Confira se funciona
- Rode
next dev, role até o rodapé e inscreva-se com um endereço seu. A frase de confira-sua-caixa-de-entrada substitui o formulário. - Abra Submissions. A inscrição tem
consentigual atrueesourceigual afooter. - Remova
requireddo checkbox nas dev tools e envie sem marcar. A mensagem de consentimento aparece e nada é armazenado. - Bloqueie
challenges.cloudflare.comnas dev tools e envie. A mensagem da verificação humana aparece e nada é armazenado. - 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.
- 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.
Pegadinhas
| Problema | O que fazer |
|---|---|
402 plan_required em todo teste local | O envio chegou ao Ikiform sem uma origem localhost. Confira a linha Origin na action e abra o app em localhost, não no seu endereço de rede. |
| A verificação humana falha em toda tentativa | A site key e o segredo vêm de widgets diferentes, ou o widget não está configurado para este hostname. As chaves de teste só passam com o segredo de teste. |
| Toda página estática virou dinâmica depois de adicionar o rodapé | Algo no formulário ou no layout chama headers() ou cookies() durante a renderização. Leia a requisição só dentro da action. |
| Failed to find Server Action, logo depois de um deploy | Uma aba ainda roda o build anterior, cujos ids de action mudaram. O leitor recarrega e funciona. |
Entregas de webhook mostram 307 ou 308 | Um redirecionamento do proxy ou a configuração de barra final capturou /api/ikiform. Exclua-o do matcher ou registre a URL com a barra. |
| 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. O Ikiform responde ao spam com o mesmo sucesso de uma inscrição real, então a action não consegue diferenciá-los. Deixe o webhook do Ikiform alimentar a ferramenta de e-mail: ele só é enviado para inscrições que não são spam.
Não com uma chave secreta, que a pula. Ou você roda o Turnstile na action, como nesta página, ou envia a partir do navegador com @ikiform/sdk, que mantém a verificação do Ikiform, os domínios permitidos e os limites por visitante.
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.