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.
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:
- Antes da hidratação, fetcher.Form é um formulário simples, então o navegador envia para /newsletter e navega até lá.
- Se /newsletter é uma resource route, o navegador mostra o JSON da action como página.
- Se /newsletter tem um componente padrão, o React Router roda a action e depois renderiza a página com o resultado como actionData.
- Depois que o JavaScript carrega, as duas versões se comportam igual: o fetcher envia em segundo plano e o leitor fica onde está.
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.
app/
root.tsx
routes.ts
routes/
home.tsx
newsletter.tsx
api.ikiform.ts
components/newsletter-form.tsx
react-router.config.ts
.envimport { 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;IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Crie 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
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 status4xx, 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
Originrepassa a origem do navegador, porque o Ikiform reconhece os testes gratuitos emlocalhostpor esse header e ofetchde 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.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.O formulário do rodapé
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.dataguarda a resposta da action depois de um envio em segundo plano;initiala guarda depois de um POST de documento. O que existir vence.fetcher.stateficasubmittinge depoisloadingantes de voltar aidle, então o botão continua desabilitado nos dois.- Uma
keypor posição dá ao formulário do rodapé e ao formulário da página/newsletterestados separados quando os dois estão na tela.
Monte sem recarregar os dados da página
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.
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
| 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
- 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. - Abra a aba de rede e inscreva-se de novo a partir de uma página recém-carregada. Há uma requisição para
/newslettere nenhuma requisição de loader depois dela. - Desative o JavaScript e inscreva-se. Você cai em
/newslettercom a mesma frase, não num JSON. - Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Se você adicionou um webhook, abra o endpoint em Webhooks. A entrega mostra
succeededcom a resposta do seu servidor. - 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
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.
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 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.
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.