# Crie um formulário de inscrição em newsletter no React Router

> Um formulário de newsletter no rodapé com React Router: useFetcher envia a uma rota /newsletter que também funciona sem JavaScript, e uma resource route alimenta o MailerLite.

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

Coloque a action numa rota `/newsletter` de verdade, com página própria, e envie para ela a partir de um formulário no rodapé feito com `useFetcher`. Com JavaScript, o fetcher envia em segundo plano e o leitor continua no artigo; sem ele, o mesmo formulário é um POST comum que cai na página `/newsletter` com o resultado. A action confere o consentimento e encaminha a inscrição ao seu backend de formulários, e uma resource route transforma o webhook assinado dele num assinante do MailerLite.

A [aba React Router do guia principal](https://ikiform.com/pt-br/guides/newsletter-signup.md#form) envia a uma resource route com `useFetcher`. Isso funciona depois que o JavaScript carrega. Esta página resolve o que acontece antes disso, impede que uma inscrição recarregue todos os loaders da página e adiciona a rota de webhook para o MailerLite. A configuração do produto está no [guia de servidor na documentação](https://ikiform.com/pt-br/docs/guides/server.md).

## Por que a action precisa de uma página

A documentação do React Router descreve `fetcher.Form` como um formulário HTML normal até o JavaScript carregar. Um envio nesse momento é um POST de documento para a URL do `action` do formulário, e o navegador mostra o que essa rota renderizar. Uma resource route não renderiza nada; ela devolve seus dados. Então o leitor que clica cedo cai num JSON cru. Dê à action uma rota com componente padrão e o mesmo POST cai numa página:

Diagrama: O que um leitor sem JavaScript vê depois de enviar o formulário do rodapé

1. Antes da hidratação, fetcher.Form é um formulário simples, então o navegador envia para /newsletter e navega até lá.
2. Se /newsletter é uma resource route, o navegador mostra o JSON da action como página.
3. Se /newsletter tem um componente padrão, o React Router roda a action e depois renderiza a página com o resultado como actionData.
4. Depois que o JavaScript carrega, as duas versões se comportam igual: o fetcher envia em segundo plano e o leitor fica onde está.

A correção é um default export. A action e o formulário do rodapé continuam iguais.

## Configuração do projeto

Isto usa o framework mode com renderização no servidor ligada, `ssr: true` em `react-router.config.ts`, que é o padrão.

`Árvore do projeto`:

```text
app/
  root.tsx
  routes.ts
  routes/
    home.tsx
    newsletter.tsx
    api.ikiform.ts
  components/newsletter-form.tsx
react-router.config.ts
.env
```

`app/routes.ts`:

```ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("newsletter", "routes/newsletter.tsx"),
  route("api/ikiform", "routes/api.ikiform.ts"),
] satisfies RouteConfig;
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

Crie a chave do Ikiform em **API keys** com o escopo `submissions:write` e cole a [definição de campos do pilar](https://ikiform.com/pt-br/guides/newsletter-signup.md#fields) na aba **Fields** do formulário.

## Construa

### A rota /newsletter

`app/routes/newsletter.tsx`:

```tsx
import { data } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";
import type { Route } from "./+types/newsletter";

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  if (form.get("consent") !== "true") {
    return data(
      { ok: false as const, field: "consent", message: "Tick the box so we know you want the emails." },
      { status: 422 },
    );
  }

  const source = String(form.get("source") ?? "");
  const 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.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email: String(form.get("email") ?? "").trim(),
      first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
      consent: true,
      source: PLACEMENTS.includes(source) ? source : "footer",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true as const };
  const email = result?.error?.fields?.email?.[0];
  return data(
    {
      ok: false as const,
      field: email ? "email" : "form",
      message: String(email ?? result?.error?.message ?? "We couldn't reach the list. Try again."),
    },
    { status: response?.status ?? 502 },
  );
}

export function meta() {
  return [{ title: "Newsletter" }];
}

export default function Newsletter({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>The monthly newsletter</h1>
      <NewsletterForm source="newsletter-page" initial={actionData} />
    </main>
  );
}
```

- Os erros são devolvidos com `data()` e um status `4xx`, não lançados, para que cheguem ao formulário em vez do error boundary.
- A checagem de consentimento roda antes da chamada de rede. Um checkbox faltando não custa nada.
- 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.
- O componente padrão renderiza o mesmo formulário, alimentado com `actionData`, então o resultado sem JavaScript e o resultado na página ficam iguais.

> 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 do rodapé

`app/components/newsletter-form.tsx`:

```tsx
import { useFetcher } from "react-router";
import type { Route } from "../routes/+types/newsletter";
import type { action } from "../routes/newsletter";

interface Props {
  source: "footer" | "post" | "newsletter-page";
  initial?: Route.ComponentProps["actionData"];
}

export function NewsletterForm({ source, initial }: Props) {
  const fetcher = useFetcher<typeof action>({ key: `newsletter-${source}` });
  const result = fetcher.data ?? initial;
  const busy = fetcher.state !== "idle";

  if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
  const error = result?.ok === false ? result : null;

  return (
    <fetcher.Form method="post" action="/newsletter">
      <label>
        Email
        <input name="email" type="email" required autoComplete="email" aria-invalid={error?.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={error?.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 />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
    </fetcher.Form>
  );
}
```

- `fetcher.data` guarda a resposta da action depois de um envio em segundo plano; `initial` a guarda depois de um POST de documento. O que existir vence.
- `fetcher.state` fica `submitting` e depois `loading` antes de voltar a `idle`, então o botão continua desabilitado nos dois.
- Uma `key` por posição dá ao formulário do rodapé e ao formulário da página `/newsletter` estados separados quando os dois estão na tela.

### Monte sem recarregar os dados da página

`app/root.tsx`:

```tsx
import type { ShouldRevalidateFunctionArgs } from "react-router";
import { Outlet } from "react-router";
import { NewsletterForm } from "~/components/newsletter-form";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/newsletter") return false;
  return defaultShouldRevalidate;
}

export default function App() {
  return (
    <>
      <Outlet />
      <footer>
        <p>One email a month. Unsubscribe from any issue.</p>
        <NewsletterForm source="footer" />
      </footer>
    </>
  );
}
```

Depois que um fetcher envia a uma action, o React Router revalida por padrão os loaders de todas as rotas correspondentes. Uma inscrição na newsletter não muda nada do que eles carregam, então `shouldRevalidate` na raiz pula esse trabalho quando o envio foi para `/newsletter`. Adicione o mesmo export a qualquer rota cujo loader seja caro. O formulário vai no componente padrão `App`, e não em `Layout`, porque `Layout` também envolve o error boundary.

## Encaminhe assinantes ao MailerLite

Webhooks vêm de fora do seu app, então este é uma resource route: uma `action` sem componente, que devolve objetos `Response` simples, como a documentação do React Router sugere para chamadores externos. Ela lê o corpo como texto primeiro porque a assinatura cobre os bytes exatos. Ela envia toda inscrição como `unconfirmed`, o que serve para uma lista nova. Se sua lista já tem leitores confirmados, use o relay do [guia do MailerLite](https://ikiform.com/pt-br/guides/newsletter-signup/mailerlite.md): ele consulta o endereço antes, então um leitor confirmado que se inscreve de novo mantém o status.

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

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import type { Route } from "./+types/api.ikiform";

export async function action({ request }: Route.ActionArgs) {
  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 answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [process.env.MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

Adicione `https://your-site.com/api/ikiform` em **Webhooks**, escolha o formulário da newsletter 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 MailerLite nunca o vê. A chave secreta pula a verificação Turnstile do Ikiform, e todo envio agora vem do IP do seu servidor. Se bots passarem, verifique um token do Turnstile na action antes do `fetch`, como mostra o [guia do Turnstile](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md).

## Renderização e pré-renderização

Você pode pré-renderizar artigos com a opção `prerender` mantendo `ssr: true`; o servidor continua rodando `/newsletter` e a rota de webhook sob demanda. O SPA mode, `ssr: false`, não funciona aqui: a documentação do React Router diz que actions são proibidas sem um servidor em runtime, e o build falha se uma rota tiver uma. Um site React Router totalmente estático deve enviar a partir do navegador com `@ikiform/sdk`.

## Deploy

| Host | O que fazer |
| --- | --- |
| Vercel | Faz deploy sem configuração. A Vercel recomenda adicionar `vercelPreset()` de `@vercel/react-router/vite` a `presets` em `react-router.config.ts`. Defina as variáveis em **Environment Variables**. |
| Netlify | Instale `@netlify/vite-plugin-react-router` e adicione-o aos plugins do Vite; o SSR roda como Netlify Functions. Defina as variáveis nas configurações de ambiente do site. |
| Cloudflare | Comece pelo template do React Router para Workers. Leia os segredos com `import { env } from "cloudflare:workers"` em vez de `process.env`; o React Router 8 não os coloca mais em `context.cloudflare.env`. Use um domínio personalizado para o webhook; o Ikiform recusa `workers.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. Teste o caminho do clique antecipado desativando o JavaScript nas dev tools. O Ikiform não entrega webhooks em `localhost`; exponha o servidor de desenvolvimento por um túnel com URL HTTPS para testar `/api/ikiform`.

## Confira se funciona

1. Rode `react-router dev`, abra um artigo e inscreva-se pelo rodapé. A frase de confira-sua-caixa-de-entrada aparece e a URL não muda.
2. Abra a aba de rede e inscreva-se de novo a partir de uma página recém-carregada. Há uma requisição para `/newsletter` e nenhuma requisição de loader depois dela.
3. Desative o JavaScript e inscreva-se. Você cai em `/newsletter` com a mesma frase, não num JSON.
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 MailerLite. O assinante está no grupo como não confirmado, e o e-mail de confirmação está na sua caixa de entrada.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| O build falha com um erro sobre actions | `ssr` está `false`. Actions precisam de um servidor em runtime; ligue o SSR e use `prerender` para as páginas estáticas. |
| Toda inscrição recarrega os dados da página | Os loaders revalidam depois de qualquer action. Adicione um `shouldRevalidate` que devolva `false` quando `formAction` for `/newsletter`. |
| A chave secreta é `undefined` na Cloudflare | Os Workers não preenchem `process.env` por padrão. Leia o segredo de `env` em `cloudflare:workers` dentro da action. |
| `402 plan_required` em testes locais | O envio não tinha origem `localhost`. Confira a linha `Origin` na action e abra o app 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

### O useFetcher funciona sem JavaScript?

Sim. fetcher.Form renderiza um formulário HTML normal, então antes da hidratação ele envia e navega até a URL da action. Faça dessa URL uma rota com componente, ou o leitor vê os dados crus da action.

### A action da newsletter deveria ser uma resource route?

Não para um formulário que pessoas enviam. Use uma rota com componente padrão para que o envio sem JavaScript mostre uma página. Deixe as resource routes para webhooks e outros chamadores que não são navegadores.

### Por que adicionar assinantes ao MailerLite por um webhook em vez da action?

Por dois motivos. A action recebe a mesma resposta de sucesso para spam e para uma inscrição real, então adicionaria bots também. E um erro do MailerLite dentro da action mostraria uma falha para uma inscrição que o Ikiform já armazenou, enquanto uma entrega de webhook que falha é tentada de novo.

### 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.
- [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.
- [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 TanStack Start](https://ikiform.com/pt-br/guides/newsletter-signup/tanstack-start.md): Um formulário de newsletter no TanStack Start: uma server function para inscrições na página, uma server route como fallback sem JavaScript, e inscrições reais enviadas ao beehiiv.
- [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.
- [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.
