Formulário de inscrição em newsletter8 min de leitura
Crie um formulário de inscrição em newsletter no TanStack Start
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.
Escreva a lógica de inscrição uma vez num módulo de servidor e chame-a de dois lugares: uma função POST createServerFn que o formulário chama na página, e uma server route para onde o mesmo formulário envia quando o JavaScript não carregou. Ambas conferem o checkbox de consentimento e encaminham a inscrição ao seu backend de formulários com uma chave secreta. Uma segunda server route recebe o webhook assinado do backend e cria a assinatura no beehiiv.
A aba TanStack Start do guia principal é uma server function e um componente. Isso cobre leitores cujo JavaScript já carregou. Esta página acrescenta o que um site que você divulga bastante precisa: um caminho para o leitor que envia antes da hidratação, uma página de resultado para esse caminho e uma rota de webhook que repassa as inscrições reais ao beehiiv. A configuração do produto está no guia de servidor na documentação.
O que muda no TanStack Start
Uma server function é chamada por uma requisição RPC feita pelo cliente, então um formulário que só conhece onSubmit não faz nada até o bundle rodar. A documentação do TanStack menciona uma propriedade .url para enviar um formulário a uma server function sem JavaScript, mas não documenta como essa resposta é renderizada. Uma server route é mais simples: recebe um POST de formulário normal e devolve um redirecionamento normal. Então o formulário ganha um action de verdade além do onSubmit:
- O leitor envia o formulário. Se o JavaScript carregou ou não decide o caminho.
- Hidratado: onSubmit impede o envio e chama a server function com os dados do formulário.
- A server function roda forwardSignup, que confere o consentimento e envia ao Ikiform, e devolve o resultado ao componente.
- Não hidratado: o navegador envia à server route /api/newsletter, que roda o mesmo forwardSignup.
- A rota responde 303 com um status ou um código de erro, e a página /newsletter mostra a mensagem correspondente.
Configuração do projeto
src/
server/newsletter.ts
components/NewsletterForm.tsx
routes/
__root.tsx
newsletter.tsx
api/newsletter.ts
api/ikiform.ts
.envO código de servidor lê os segredos de process.env. Nunca dê a eles o prefixo VITE_: o Vite coloca variáveis VITE_ no bundle do cliente. 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
BEEHIIV_API_KEY=your_beehiiv_api_key
BEEHIIV_PUBLICATION_ID=pub_00000000-0000-0000-0000-000000000000Construa
O módulo de inscrição
export interface Signup {
email: string;
first_name?: string;
consent: boolean;
source: string;
_gotcha: string;
}
export type Outcome = { ok: true } | { ok: false; field: "email" | "consent" | "failed"; message: string };
const PLACEMENTS = new Set(["footer", "post", "newsletter-page"]);
export function readSignup(form: FormData): Signup {
const source = String(form.get("source") ?? "");
return {
email: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim().slice(0, 80) || undefined,
consent: form.get("consent") === "true",
source: PLACEMENTS.has(source) ? source : "footer",
_gotcha: String(form.get("_gotcha") ?? ""),
};
}
export async function forwardSignup(signup: Signup, origin: string | undefined): Promise<Outcome> {
if (!signup.consent) {
return { ok: false, field: "consent", message: "Tick the box so we know you want the emails." };
}
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 ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...signup, consent: true }),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true };
const email = result?.error?.fields?.email?.[0];
if (email) return { ok: false, field: "email", message: email };
return { ok: false, field: "failed", message: result?.error?.message ?? "We couldn't reach the list. Try again." };
}readSignuptransformaFormDatanum objeto simples. As duas portas de entrada o usam, então o nome de cada campo fica num lugar só.sourceprecisa ser uma das três posições, então um envio forjado não consegue inventar novas.- 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. - Toda falha devolve um
Outcomeem vez de lançar erro, para que tanto o componente quanto a rota possam mostrá-la.
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.A server function e o formulário
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
import { forwardSignup, type Outcome, readSignup } from "../server/newsletter";
const subscribe = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return readSignup(data);
})
.handler(({ data }) => forwardSignup(data, getRequestHeader("origin")));
export function NewsletterForm({ source = "footer" }: { source?: string }) {
const send = useServerFn(subscribe);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ ok: false, field: "failed", message: "Couldn't subscribe. Try again." });
} finally {
setPending(false);
}
}
if (outcome?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
const error = outcome?.ok === false ? outcome : null;
return (
<form method="post" action="/api/newsletter" onSubmit={onSubmit}>
<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={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}.validatorrecebe o que quer que o cliente tenha enviado. Conferir se éFormDataali faz uma chamada JSON forjada falhar antes de o handler rodar.getRequestHeadervem de@tanstack/react-start/servere lê a requisição que trouxe a chamada RPC, enviada pelo navegador com a origem da sua página.- O
FormDataé montado antes do primeiroawait, enquantoevent.currentTargetainda aponta para o formulário. useServerFnfaz a chamada seguir redirecionamentos e erros pelo router, o que importa se depois você lançarredirect()no handler.
A rota e a página sem JavaScript
import { createFileRoute } from "@tanstack/react-router";
import { forwardSignup, readSignup } from "../../server/newsletter";
export const Route = createFileRoute("/api/newsletter")({
server: {
handlers: {
POST: async ({ request }) => {
const signup = readSignup(await request.formData());
const outcome = await forwardSignup(signup, request.headers.get("origin") ?? undefined);
const target = new URL("/newsletter", request.url);
target.searchParams.set(outcome.ok ? "status" : "error", outcome.ok ? "subscribed" : outcome.field);
return Response.redirect(target, 303);
},
},
},
});import { createFileRoute } from "@tanstack/react-router";
import { NewsletterForm } from "../components/NewsletterForm";
const MESSAGES = {
consent: "Tick the box so we know you want the emails.",
email: "Check the email address and try again.",
failed: "We couldn't reach the list. Try again.",
};
export const Route = createFileRoute("/newsletter")({
validateSearch: (search: Record<string, unknown>) => ({
status: search.status === "subscribed" ? ("subscribed" as const) : undefined,
error: typeof search.error === "string" && search.error in MESSAGES ? (search.error as keyof typeof MESSAGES) : undefined,
}),
component: NewsletterPage,
});
function NewsletterPage() {
const { status, error } = Route.useSearch();
if (status === "subscribed") return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<main>
<h1>The newsletter</h1>
{error ? <p role="alert">{MESSAGES[error]}</p> : null}
<NewsletterForm source="newsletter-page" />
</main>
);
}A rota redireciona com 303, então recarregar a página de resultado não envia o formulário de novo. Ela passa um código de erro, nunca a mensagem do Ikiform, e validateSearch descarta qualquer coisa fora dos três códigos, então ninguém consegue colocar um texto próprio na sua página por meio de um link.
Encaminhe assinantes ao beehiiv
Esta server route recebe o webhook assinado do Ikiform e cria a assinatura no beehiiv com double_opt_override: "on", para que o beehiiv envie a confirmação por e-mail. Ela lê o corpo com request.text() antes de qualquer coisa, porque a assinatura cobre os bytes exatos. O guia do beehiiv acrescenta o primeiro nome, o referrer e os headers de limite de taxa do beehiiv.
import { createFileRoute } from "@tanstack/react-router";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export const Route = createFileRoute("/api/ikiform")({
server: {
handlers: {
POST: async ({ 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, consent, source } = event.data.fields;
if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });
const answer = await fetch(
`https://api.beehiiv.com/v2/publications/${process.env.BEEHIIV_PUBLICATION_ID}/subscriptions`,
{
method: "POST",
headers: { Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
email,
double_opt_override: "on",
reactivate_existing: false,
utm_source: typeof source === "string" ? source : "website",
utm_medium: "signup_form",
}),
},
);
if (answer.status === 429) return new Response("beehiiv rate limit", { status: 503, headers: { "retry-after": "60" } });
if (!answer.ok) return new Response(`beehiiv 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_ para IKIFORM_WEBHOOK_SECRET.
Honeypot e a verificação humana
O input _gotcha vai junto com a inscrição, e o Ikiform armazena um preenchido como spam, com resposta de sucesso e sem webhook, então o beehiiv nunca o vê. A chave secreta pula a verificação Turnstile do Ikiform. Se bots passarem pelo honeypot e pelo checkbox obrigatório, verifique um token do Turnstile em forwardSignup, como mostra o guia do Turnstile, para que as duas portas de entrada o tenham.
Renderização
O Start renderiza as rotas no servidor por padrão, então o HTML do formulário, com action e tudo, está na primeira resposta. Se você pré-renderiza páginas como arquivos estáticos, o formulário continua funcionando nelas: a server function e as duas server routes rodam sob demanda no host.
Deploy
| Host | O que fazer |
|---|---|
| Vercel | Adicione o Nitro: instale nitro e coloque nitro() de nitro/vite nos plugins do Vite. Defina as quatro variáveis em Environment Variables. |
| Netlify | Adicione @netlify/vite-plugin-tanstack-start e coloque netlify() nos plugins do Vite. Defina as variáveis nas configurações de ambiente do site. |
| Cloudflare | Adicione @cloudflare/vite-plugin primeiro nos plugins, com um wrangler.jsonc que liga nodejs_compat. Com essa flag e uma compatibility date recente, os Workers expõem variáveis e segredos em process.env. Use um domínio personalizado para a URL do 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 os dois caminhos: uma vez normalmente e outra com o JavaScript desativado nas dev tools. O Ikiform não entrega webhooks em localhost, então teste /api/ikiform por um túnel com URL HTTPS.
Confira se funciona
- Rode o servidor de desenvolvimento e inscreva-se pelo rodapé. O formulário é substituído pela frase de confira-sua-caixa-de-entrada e a URL não muda.
- Desative o JavaScript e inscreva-se de novo. Você cai em
/newsletter?status=subscribed. - Ainda com o JavaScript desligado, envie com
requiredremovido e o checkbox desmarcado. Você cai em/newsletter?error=consentcom a mensagem de consentimento. - 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 beehiiv. A assinatura está lá, pendente até você confirmar, e o e-mail de confirmação está na sua caixa de entrada.
Pegadinhas
| Problema | O que fazer |
|---|---|
A chave secreta é undefined em produção | O host não recebeu a variável, ou ela foi lida em código que roda no navegador. Leia segredos só dentro de server functions e server routes. |
| Uma página de erro em JSON depois de enviar sem JavaScript | O action do formulário aponta para a server function em vez da server route. Envie para /api/newsletter. |
402 plan_required em testes locais | O envio chegou ao Ikiform sem uma origem localhost. Confira se forwardSignup recebe a origem dos dois chamadores. |
Entregas de webhook falham com 400 | O segredo não bate com o do endpoint, ou algo interpretou o corpo antes de request.text(). Leia o texto bruto primeiro. |
| 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 documentação do TanStack menciona uma propriedade .url para enviar um formulário a uma server function, mas uma server route é o fallback mais simples: recebe um POST de formulário comum e redireciona. Esta página usa as duas, com um módulo compartilhado por trás.
Em variáveis de ambiente só de servidor, lidas com process.env dentro de server functions e server routes. Variáveis com o prefixo VITE_ acabam no bundle do navegador.
O Ikiform responde ao spam com o mesmo sucesso de uma inscrição real. O webhook dele só é enviado para inscrições que não são spam, então criar a assinatura a partir do webhook mantém bots fora da sua lista.
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.