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

Preet SutharFundador, Ikiform

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ãoO que aconteceO que esta implementação faz
Páginas estáticasO formulário não pode ler cookies nem headers durante a renderização, senão toda página vira dinâmicaO formulário não lê nada na renderização. Só a action toca a requisição
A verificação humanaUm envio com chave secreta pula a verificação Turnstile do IkiformRoda a própria verificação Turnstile dentro da action
Quem pode chamarUma server action é um endpoint POST público, acessível sem o seu formulárioValida o consentimento e o token antes de enviar qualquer coisa
Como uma inscrição no rodapé do Next.js chega ao Ikiform e depois ao Kitenquanto o leitor esperasegundos depoisformulárionorodapérootlayout,todapáginaserveractionconsentimento,depoisTurnstileIkiformchavesecreta,armazenainscriçãoactionstateconfirasuacaixadeentradaestadodeerronadaenviado,e-mailmantidoIkiformsó senão forspam/api/ikiformverificaaassinaturaKitenvia aconfirmaçãoFormDataJSON + Originresultadochecagem falhawebhook assinadov4 APIComo uma inscrição no rodapé do Next.js chega ao Ikiform e depois ao Kitenquanto o leitor esperasegundos depoisformulário norodapéroot layout, todapáginaserveractionconsentimento,depoisTurnstileIkiformchave secreta,armazena inscriçãoaction stateconfira sua caixa deentradaestadode erronadaenviado,e-mailmantidoIkiformsó se não for spam/api/ikiformverifica aassinaturaKitenvia a confirmaçãoFormDataJSON + Originresultadowebhook assinadov4 APIchecagem falha
  1. O leitor preenche o formulário do rodapé. Ele envia à server action da página que o leitor estiver lendo.
  2. A action confere o checkbox de consentimento e depois valida o token do Turnstile com a Cloudflare.
  3. Se alguma checagem falhar, o formulário volta com a mensagem e o e-mail digitado. Nada é enviado.
  4. Caso contrário, a action envia a inscrição ao Ikiform com a chave secreta e o Origin do navegador.
  5. A resposta do Ikiform vira o action state, e o formulário é substituído pela frase de confira-sua-caixa-de-entrada.
  6. 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ê.
A action nunca chama o Kit diretamente. O Ikiform responde ao spam com o mesmo sucesso de uma inscrição real, então só o webhook sabe quais inscrições são reais.

Configuração do projeto

Árvore do projeto
app/
  layout.tsx
  newsletter/
    actions.ts
    newsletter-form.tsx
    submit-button.tsx
    page.tsx
  api/ikiform/route.ts
lib/
  turnstile.ts
.env.local

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

.env.local
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=1234567

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

lib/turnstile.ts
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

app/newsletter/actions.ts
"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 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.
  • Todo erro devolve o e-mail e o nome digitados. O React reseta o formulário depois de uma action, e defaultValue os 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.
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 formulário e seu estado pendente

app/newsletter/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}
app/newsletter/newsletter-form.tsx
"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>
  );
}
  • useFormStatus precisa ser lido num filho do <form>, e é por isso que o botão é um componente próprio.
  • useId mantém os ids dos inputs únicos quando o formulário do rodapé e um formulário da página /newsletter aparecem juntos na tela.
  • O script do Turnstile carrega com ?render=explicit. render coloca o widget dentro do formulário, que então envia um input oculto cf-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

app/layout.tsx
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.

app/api/ikiform/route.ts
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, antigo middleware.ts, que redireciona visitantes sem sessão, deixe /api/ikiform fora do matcher dele. Senão toda entrega recebe o seu redirecionamento de login em vez do handler.
  • Com trailingSlash: true no next.config, o Next.js redireciona /api/ikiform para /api/ikiform/. Registre a URL com a barra.
  • Um 503 com Retry-After faz o Ikiform esperar e tentar de novo, e um 502 coloca 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

HostO que fazer
VercelAdicione 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.
NetlifyA 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.
CloudflareRode 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

  1. 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.
  2. Abra Submissions. A inscrição tem consent igual a true e source igual a footer.
  3. Remova required do checkbox nas dev tools e envie sem marcar. A mensagem de consentimento aparece e nada é armazenado.
  4. Bloqueie challenges.cloudflare.com nas dev tools e envie. A mensagem da verificação humana aparece e nada é armazenado.
  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.
  7. 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

ProblemaO que fazer
402 plan_required em todo teste localO 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 tentativaA 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 deployUma aba ainda roda o build anterior, cujos ids de action mudaram. O leitor recarrega e funciona.
Entregas de webhook mostram 307 ou 308Um 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 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.