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

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

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](https://ikiform.com/pt-br/guides/newsletter-signup.md#form) é 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](https://ikiform.com/pt-br/docs/guides/server.md).

## 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`:

Diagrama: Como um formulário do TanStack Start chega ao mesmo código de inscrição com ou sem JavaScript

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`:

```text
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](https://ikiform.com/pt-br/guides/newsletter-signup.md#fields) na aba **Fields** do formulário.

`.env`:

```bash
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`:

```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](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.

### A server function e o formulário

`src/components/NewsletterForm.tsx`:

```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`:

```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`:

```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](https://ikiform.com/pt-br/guides/newsletter-signup/beehiiv.md) acrescenta o primeiro nome, o referrer e os headers de limite de taxa do beehiiv.

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

```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](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md), 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

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

| 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

### As server functions do TanStack Start funcionam sem JavaScript?

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.

### Onde ficam os segredos no TanStack Start?

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.

### Por que não adicionar o assinante ao beehiiv dentro da server function?

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.

### 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.
- [beehiiv](https://ikiform.com/pt-br/guides/newsletter-signup/beehiiv.md): Conecte seu próprio formulário de inscrição ao beehiiv: um relay cria cada subscription com double opt-in ligado, a posição como UTM source e a página como referrer.
- [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 React Router](https://ikiform.com/pt-br/guides/newsletter-signup/react-router.md): 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.
- [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.
