Formulário de inscrição em newsletter

Formulário de inscrição em newsletter8 min de leitura

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.

Preet SutharFundador, Ikiform

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

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:

O que um leitor sem JavaScript vê depois de enviar o formulário do rodapéaction numa resource routeaction numa rota de UIformulário norodapéfetcher.Form, nãohidratado/newsletteraction, semcomponenteJSON cru{"ok":true}formulário norodapéfetcher.Form, nãohidratado/newsletteraction +componentepadrãopágina deresultadoactionDatarenderizadoPOST de documentorespostaPOST de documentorenderizaO que um leitor sem JavaScript vê depois de enviar o formulário do rodapéaction numa resource routeaction numa rota de UIformulário no rodapéfetcher.Form, não hidratado/newsletteraction, sem componenteJSON cru{"ok":true}formulário no rodapéfetcher.Form, não hidratado/newsletteraction + componente padrãopágina de resultadoactionData renderizadoPOST de documentorespostaPOST de documentorenderiza
  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
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
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
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 na aba Fields do formulário.

Construa

A rota /newsletter

app/routes/newsletter.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 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
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
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: 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
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.

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

HostO que fazer
VercelFaz 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.
NetlifyInstale @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.
CloudflareComece 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

ProblemaO que fazer
O build falha com um erro sobre actionsssr 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áginaOs loaders revalidam depois de qualquer action. Adicione um shouldRevalidate que devolva false quando formAction for /newsletter.
A chave secreta é undefined na CloudflareOs 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 locaisO 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 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.