# Crie um formulário de feedback dentro do app em TanStack Start

> Um formulário de feedback dentro do app em TanStack Start: uma server function POST com middleware de sessão, o id da rota como contexto e a página lida dos headers.

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

Escreva uma server function POST para o feedback e anexe um function middleware que lê a sessão e coloca o usuário em `context`. A função adiciona esse usuário, a página do `Referer` da requisição e o id da rota correspondente à avaliação, e depois encaminha tudo ao seu backend de formulários com uma chave secreta. Monte o formulário uma vez na rota de layout logada, e ele sabe sozinho em qual tela está.

A [aba TanStack Start do guia principal](https://ikiform.com/pt-br/guides/feedback-form.md#form) envia feedback de página de qualquer pessoa, com a página definida no navegador. Dentro de um app com login dá para fazer melhor: o servidor sabe quem está perguntando, e o router sabe qual tela está aberta. Configuração da conta, chaves e domínios permitidos são cobertos uma vez no [guia de servidor na documentação](https://ikiform.com/pt-br/docs/guides/server.md).

## Quem fornece o quê

No Start, uma server function é um endpoint HTTP em `/_serverFn/` que o seu componente chama por um cliente gerado. Qualquer coisa no input pode ser reescrita por quem a chama. Então o input leva só o que o usuário escolheu, e tudo sobre identidade vem de um middleware que roda no servidor antes do handler.

| Valor | Origem | Confiança |
| --- | --- | --- |
| `rating`, `comment` | O `FormData` do formulário | As palavras do próprio usuário |
| `user_id`, `email` | `context.user`, definido pelo middleware de sessão | Lido de um cookie selado no servidor |
| `page` | `getRequestHeader("referer")` dentro do handler | O navegador chama a função a partir da página aberta, então o header traz a URL dela |
| `screen` | O `routeId` da correspondência mais profunda, como `/_app/projects/$projectId/settings` | Um rótulo de agrupamento. Conferido contra um padrão antes de ser armazenado |

Diagrama: Como uma server function do TanStack Start adiciona o usuário e a página a uma resposta de feedback

1. O formulário coleta a nota e o comentário, adiciona o id da rota atual e chama a server function.
2. No servidor, o middleware lê a sessão e repassa o usuário como contexto.
3. O validator transforma o FormData num objeto tipado e descarta um nome de tela com formato errado.
4. O handler recusa um usuário ausente e depois adiciona o usuário e a página do header Referer.
5. Ele envia ao Ikiform com a chave secreta e devolve sent, ou um erro que o formulário mostra.

O usuário vem do cookie, no servidor. Nada nos dados do formulário consegue mudar de quem é uma resposta.

## Configuração do projeto

A árvore assume roteamento baseado em arquivos com um layout `_app` sem caminho que envolve toda tela logada. `session.ts` é o helper do iron-session do guia de autenticação do Start; qualquer biblioteca de sessão que lê um cookie funciona do mesmo jeito. `findUser` em `users.ts` e `getCurrentUserFn` em `server/user.ts` são as suas próprias consultas de usuário.

`Árvore do projeto`:

```text
src/
  components/feedback-form.tsx
  lib/
    session.ts
    user-middleware.ts
    users.ts
  routes/
    _app.tsx
    _app/projects/$projectId/settings.tsx
  server/
    feedback.ts
    user.ts
.env
```

O Start lê variáveis sem prefixo de `process.env` só no servidor. Qualquer coisa com o prefixo `VITE_` vai no bundle do navegador, então a chave nunca pode tê-lo. Leia a chave dentro do handler, não no topo do módulo.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup
```

Cole a definição dos campos na aba **Fields** do formulário. O formulário mostra dois deles; a server function preenche os outros quatro, e o campo `email` faz o **Reply** de cada notificação ir para o usuário.

Formulário: In-app feedback

- **Como esta tela está funcionando para você?** (`rating`, rating, obrigatório)
- **O que a deixaria melhor?** (`comment`, textarea)
- **Página** (`page`, url)
- **Tela** (`screen`, text)
- **Usuário** (`user_id`, text)
- **E-mail** (`email`, email)

Envie `rating: 6` ou um campo que a definição não lista, e a prévia mostra o `422` que a sua server function receberia.

## Construa

### Middleware de sessão

Um function middleware envolve qualquer server function que o liste. O que entra em `next({ context })` chega tipado no handler. Ele passa `null` em vez de lançar erro, para que o handler responda com uma mensagem que o formulário mostra.

`src/lib/user-middleware.ts`:

```ts
import { createMiddleware } from "@tanstack/react-start";
import { getAppSession } from "./session";
import { findUser } from "./users";

export const userMiddleware = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const session = await getAppSession();
  const user = session.userId ? await findUser(session.userId) : null;
  return next({ context: { user: user ? { id: user.id, email: user.email } : null } });
});
```

### A server function

`src/server/feedback.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { userMiddleware } from "../lib/user-middleware";

export type FeedbackResult = { status: "sent" } | { status: "error"; message: string };

const ROUTE_ID = /^\/[A-Za-z0-9/$_.-]{0,119}$/;

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export const sendFeedback = createServerFn({ method: "POST" })
  .middleware([userMiddleware])
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const rating = Number(data.get("rating"));
    const screen = String(data.get("screen") ?? "");
    return {
      rating: Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null,
      comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
      screen: ROUTE_ID.test(screen) ? screen : "unknown",
      trap: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data, context }): Promise<FeedbackResult> => {
    if (!context.user) return { status: "error", message: "You're signed out. Sign in and send it again." };
    if (data.rating === null) return { status: "error", message: "Choose a score first." };

    const origin = getRequestHeader("origin");
    let response: Response;
    try {
      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({
          rating: data.rating,
          comment: data.comment,
          page: pageFrom(getRequestHeader("referer")),
          screen: data.screen,
          user_id: context.user.id,
          email: context.user.email,
          _gotcha: data.trap,
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return { status: "error", message: "Feedback is unreachable right now. Try again shortly." };
    }

    const result = await response.json().catch(() => null);
    if (result?.ok) return { status: "sent" };
    return { status: "error", message: result?.error?.message ?? "That didn't go through. Try again." };
  });
```

- `.validator` recebe o `FormData` bruto e devolve um objeto simples. Uma nota inválida vira `null` em vez de um erro lançado, que chegaria ao cliente como uma falha genérica.
- `getRequestHeader` lê a requisição recebida de qualquer lugar do handler, sem passar um objeto de requisição adiante.
- `Origin` é repassado da chamada do navegador. Ele decide se o Ikiform trata o envio como teste local, como explicado abaixo.
- Só um id e um e-mail entram no contexto, não o registro inteiro do usuário. O que o handler devolve é serializado para o navegador.

### O formulário

`src/components/feedback-form.tsx`:

```tsx
import { useMatches } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { type FeedbackResult, sendFeedback } from "../server/feedback";

const SCORES = ["Badly", "Poorly", "Okay", "Well", "Very well"];

export function FeedbackForm() {
  const send = useServerFn(sendFeedback);
  const screen = useMatches({ select: (matches) => matches.at(-1)?.routeId ?? "unknown" });
  const [result, setResult] = useState<FeedbackResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("screen", screen);
    setPending(true);
    try {
      setResult(await send({ data }));
    } catch {
      setResult({ status: "error", message: "We lost the connection. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (result?.status === "sent") return <p role="status">Thanks. We read every one.</p>;

  return (
    <form method="post" onSubmit={onSubmit} aria-busy={pending}>
      <fieldset>
        <legend>How is this screen working for you?</legend>
        {SCORES.map((label, index) => (
          <label key={label}>
            <input type="radio" name="rating" value={index + 1} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows={4} maxLength={4000} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {result?.status === "error" ? <p role="alert">{result.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

- `useMatches` com `select` devolve só o `routeId` da última correspondência, então o componente renderiza de novo quando a tela muda, não a cada atualização do router.
- `onSubmit` impede o envio nativo e chama a função. Nada reseta o formulário, então depois de um erro o comentário continua na caixa.
- `pending` desativa o botão durante a chamada, o que impede um envio duplicado.

### Monte na rota de layout

`src/routes/_app.tsx`:

```tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { FeedbackForm } from "../components/feedback-form";
import { getCurrentUserFn } from "../server/user";

export const Route = createFileRoute("/_app")({
  beforeLoad: async () => {
    const user = await getCurrentUserFn();
    if (!user) throw redirect({ to: "/login" });
    return { user };
  },
  component: () => (
    <>
      <Outlet />
      <aside>
        <FeedbackForm />
      </aside>
    </>
  ),
});
```

`beforeLoad` mantém usuários deslogados longe das telas, mas não é isso que protege o feedback: qualquer um pode chamar `/_serverFn/` diretamente, e por isso o middleware confere de novo. Os ids de rota incluem layouts sem caminho, daí o prefixo `/_app` em `screen`. Remova-o antes de armazenar se preferir nomes mais curtos.

## Honeypot, verificação humana e limites

O input `_gotcha` passa intacto pelo validator, e o Ikiform registra um preenchido como spam. A verificação humana é pulada em envios feitos com chave secreta, o que tudo bem atrás de uma sessão: bots não têm uma. O que sobra é uma conta logada enviando em loop, e o Ikiform conta todos esses envios contra o endereço do seu servidor.

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.

## SSR, modo SPA e pré-renderização

O formulário funciona igual com SSR completo ou com `spa: { enabled: true }` nas opções do `tanstackStart()`. No modo SPA, só o shell raiz é pré-renderizado e as telas renderizam no navegador, mas as server functions ainda precisam de um servidor rodando: se um host reescreve todo caminho para o shell, adicione `/_serverFn/*` aos caminhos que ele deixa passar. Não pré-renderize telas logadas; não existe usuário no build.

A documentação do Start também menciona uma propriedade `.url` para chamar uma server function a partir de um formulário HTML simples. Esta página não a usa. Uma tela logada já está hidratada antes de alguém digitar um comentário, e um envio sem script exigiria que a função respondesse com um redirect em vez de dados. Considere que o formulário precisa de JavaScript.

## Deploy

| Host | Configuração |
| --- | --- |
| Vercel | Adicione `nitro()` de `nitro/vite` aos plugins do Vite, faça o deploy e adicione `IKIFORM_SECRET_KEY` às variáveis de ambiente do projeto. |
| Netlify | Adicione `netlify()` de `@netlify/vite-plugin-tanstack-start` e defina a chave nas variáveis de ambiente do site. |
| Cloudflare | Adicione `cloudflare({ viteEnvironment: { name: "ssr" } })` de `@cloudflare/vite-plugin`, ative `nodejs_compat` e guarde a chave com `wrangler secret put`. Com uma compatibility date de 2025-04-01 ou posterior, essa flag preenche `process.env` com os seus secrets. |

## Testes gratuitos a partir do localhost

Sem plano, um workspace aceita 50 envios de teste por mês quando o header `Origin` indica `localhost`. O `fetch` do seu próprio servidor não tem origem, então um envio sem o header repassado recebe `402 plan_required` e fica retido. Com ele, `vite dev` em `http://localhost:3000` repassa a origem do navegador e cada resposta conta como teste. Em produção, a mesma linha envia o domínio do seu app.

## Confira se funciona

1. Rode `vite dev`, faça login, abra as configurações de um projeto e envie um 2 com um comentário.
2. Em **Submissions**, a resposta tem `user_id`, `email`, `screen` com o id da rota e `page` com o id real do projeto.
3. Saia da conta em outra aba e envie de novo. O formulário mostra a mensagem de sessão encerrada e mantém o comentário.
4. Chame a função pelo console do navegador com um id de usuário forjado nos dados do formulário. O `user_id` armazenado continua sendo o da sessão.
5. Responda ao e-mail de notificação. Ele vai para o usuário logado.

## Pegadinhas

| Problema | O que fazer |
| --- | --- |
| As server functions perderam a verificação de CSRF depois que você adicionou `src/start.ts` | Sem `src/start.ts`, o próprio Start adiciona seu middleware de CSRF para server functions. Quando você define um, registre `createCsrfMiddleware` ali você mesmo, filtrado para server functions. |
| `402 plan_required` nos testes locais | O header `Origin` não chegou ao Ikiform. Confira a linha que o repassa e abra o app em `localhost`, não num endereço da rede local. |
| `IKIFORM_SECRET_KEY` fica undefined na Cloudflare | Os Workers só preenchem `process.env` com `nodejs_compat` ativado e uma compatibility date de 2025-04-01 ou posterior. Confira os dois em `wrangler.jsonc`, ou leia a chave de `env` em `cloudflare:workers`. |
| `screen` é sempre `unknown` | O id da rota não passou no padrão, muitas vezes por causa de caracteres como `{` de parâmetros opcionais. Amplie o padrão para cobrir as suas rotas. |
| `422 validation_failed` | O envio não corresponde à definição de campos do formulário. `error.fields` aponta cada campo e o motivo. Faça o `name` do input bater com o id do campo. |

## Perguntas

### Como pegar o usuário atual dentro de uma server function do TanStack Start?

Escreva um function middleware que lê a sessão e chama next com o usuário no contexto, e liste-o no middleware da server function. O handler lê context.user, tipado, no servidor.

### Uma server function do TanStack Start consegue ler os headers da requisição?

Sim. getRequestHeader e getRequest de @tanstack/react-start/server leem a requisição recebida dentro do handler, então você pode pegar a página do Referer sem enviá-la do navegador.

### O formulário de feedback funciona no modo SPA do TanStack Start?

Sim, desde que um servidor rode as suas server functions e o seu host não reescreva os caminhos /_serverFn/ para o shell da SPA.

### 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 feedback para um site ou app](https://ikiform.com/pt-br/guides/feedback-form.md): Coloque um formulário de feedback em qualquer página ou dentro do seu app: uma nota, um comentário, a página de origem, filtro de spam e alertas para notas baixas.
- [Formulário de feedback com React Router](https://ikiform.com/pt-br/guides/feedback-form/react-router.md): Um formulário de feedback com React Router 8: a action de uma resource route lê a sessão do cookie, um fetcher envia do layout e um envio sem JS redireciona de volta.
- [Formulário de feedback em Next.js](https://ikiform.com/pt-br/guides/feedback-form/nextjs.md): Um formulário de feedback dentro do app em Next.js: uma server action que lê a sessão, pega a página da requisição e encaminha uma avaliação com o usuário anexado.
- [Widget de feedback em React](https://ikiform.com/pt-br/guides/feedback-form/react-feedback-widget.md): Um botão de feedback flutuante para um app React que abre um pequeno painel, envia a tela, a versão do app e a conta com cada resposta e nunca perde um rascunho.
- [Formulário de relato de bug](https://ikiform.com/pt-br/guides/feedback-form/bug-report.md): Um formulário de relato de bug que pede às pessoas os passos e a gravidade, e deixa a página adicionar sozinha a URL, o navegador, o viewport e os erros recentes.
- [Formulário de pedido de funcionalidade](https://ikiform.com/pt-br/guides/feedback-form/feature-request.md): Um formulário de pedido de funcionalidade que pergunta pelo problema em vez da solução, armazena pedidos por tema, conta pessoas, não linhas, e avisa quando sair.
