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

Preet SutharFundador, Ikiform

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:

Como um formulário do TanStack Start chega ao mesmo código de inscrição com ou sem JavaScriptNewsletterFormaction="/api/newsletter"hidratado?onSubmitconectadoserverfunctionRPC,mesmapáginaforwardSignup()consentimento,depoisIkiformresultadorenderizadono lugar/api/newsletterserverroute,POST doformulário303para/newsletter?statusou?errorenviarsimdadosResultadoainda nãoComo um formulário do TanStack Start chega ao mesmo código de inscrição com ou sem JavaScriptNewsletterFormaction="/api/newsletter"hidratado?onSubmitconectadoserver functionRPC, mesma páginaforwardSignup()consentimento, depoisIkiformresultadorenderizado no lugar/api/newsletterserver route,POST doformulário303 para/newsletter?status ou?errorenviarsimdadosResultadoainda não
  1. O leitor envia o formulário. Se o JavaScript carregou ou não decide o caminho.
  2. Hidratado: onSubmit impede o envio e chama a server function com os dados do formulário.
  3. A server function roda forwardSignup, que confere o consentimento e envia ao Ikiform, e devolve o resultado ao componente.
  4. Não hidratado: o navegador envia à server route /api/newsletter, que roda o mesmo forwardSignup.
  5. A rota responde 303 com um status ou um código de erro, e a página /newsletter mostra a mensagem correspondente.
forwardSignup é o único código que fala com o Ikiform, então os dois caminhos validam e reportam erros do mesmo jeito.

Configuração do projeto

Árvore do projeto
src/
  server/newsletter.ts
  components/NewsletterForm.tsx
  routes/
    __root.tsx
    newsletter.tsx
    api/newsletter.ts
    api/ikiform.ts
.env

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

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

Construa

O módulo de inscrição

src/server/newsletter.ts
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." };
}
  • readSignup transforma FormData num objeto simples. As duas portas de entrada o usam, então o nome de cada campo fica num lugar só.
  • source precisa ser uma das três posições, então um envio forjado não consegue inventar novas.
  • 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.
  • Toda falha devolve um Outcome em vez de lançar erro, para que tanto o componente quanto a rota possam mostrá-la.
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.

A server function e o formulário

src/components/NewsletterForm.tsx
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>
  );
}
  • .validator recebe o que quer que o cliente tenha enviado. Conferir se é FormData ali faz uma chamada JSON forjada falhar antes de o handler rodar.
  • getRequestHeader vem de @tanstack/react-start/server e lê a requisição que trouxe a chamada RPC, enviada pelo navegador com a origem da sua página.
  • O FormData é montado antes do primeiro await, enquanto event.currentTarget ainda aponta para o formulário.
  • useServerFn faz a chamada seguir redirecionamentos e erros pelo router, o que importa se depois você lançar redirect() no handler.

A rota e a página sem JavaScript

src/routes/api/newsletter.ts
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);
      },
    },
  },
});
src/routes/newsletter.tsx
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.

src/routes/api/ikiform.ts
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

HostO que fazer
VercelAdicione o Nitro: instale nitro e coloque nitro() de nitro/vite nos plugins do Vite. Defina as quatro variáveis em Environment Variables.
NetlifyAdicione @netlify/vite-plugin-tanstack-start e coloque netlify() nos plugins do Vite. Defina as variáveis nas configurações de ambiente do site.
CloudflareAdicione @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

  1. 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.
  2. Desative o JavaScript e inscreva-se de novo. Você cai em /newsletter?status=subscribed.
  3. Ainda com o JavaScript desligado, envie com required removido e o checkbox desmarcado. Você cai em /newsletter?error=consent com a mensagem de consentimento.
  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 beehiiv. A assinatura está lá, pendente até você confirmar, e o e-mail de confirmação está na sua caixa de entrada.

Pegadinhas

ProblemaO que fazer
A chave secreta é undefined em produçãoO 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 JavaScriptO action do formulário aponta para a server function em vez da server route. Envie para /api/newsletter.
402 plan_required em testes locaisO envio chegou ao Ikiform sem uma origem localhost. Confira se forwardSignup recebe a origem dos dois chamadores.
Entregas de webhook falham com 400O 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 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.