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

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

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](https://ikiform.com/pt-br/guides/newsletter-signup.md#form) 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](https://ikiform.com/pt-br/docs/guides/nextjs.md).

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

Diagrama: Como uma inscrição no rodapé do Next.js chega ao Ikiform e depois ao Kit

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](https://ikiform.com/pt-br/guides/newsletter-signup/kit.md) 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`:

```text
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`:

```bash
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](https://ikiform.com/pt-br/guides/newsletter-signup.md#fields) 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`:

```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`:

```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](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 formulário e seu estado pendente

`app/newsletter/submit-button.tsx`:

```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`:

```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`:

```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](https://ikiform.com/pt-br/guides/newsletter-signup/kit.md) cobre tags e o que cada resposta do Kit significa.

`app/api/ikiform/route.ts`:

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

| 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

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

| 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

### A server action deveria adicionar o assinante direto na minha ferramenta de e-mail?

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.

### Posso usar a verificação humana do próprio Ikiform a partir de uma server action do Next.js?

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.

### 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 Astro](https://ikiform.com/pt-br/guides/newsletter-signup/astro.md): Um formulário de newsletter no Astro que funciona em páginas pré-renderizadas: uma Astro Action confere o consentimento, envia ao Ikiform, e um endpoint adiciona leitores ao MailerLite.
- [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.
