Formulário de feedback para um site ou app

Formulário de feedback para um site ou app9 min de leitura

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.

Preet SutharFundador, Ikiform

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

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.

ValorOrigemConfiança
rating, commentO FormData do formulárioAs palavras do próprio usuário
user_id, emailcontext.user, definido pelo middleware de sessãoLido de um cookie selado no servidor
pagegetRequestHeader("referer") dentro do handlerO navegador chama a função a partir da página aberta, então o header traz a URL dela
screenO routeId da correspondência mais profunda, como /_app/projects/$projectId/settingsUm rótulo de agrupamento. Conferido contra um padrão antes de ser armazenado
Como uma server function do TanStack Start adiciona o usuário e a página a uma resposta de feedbackFeedbackFormFormData+routeIduserMiddlewarelê ocookiedesessãovalidatornota,comentário,formatodatelahandleradicionausuárioepáginadoRefererIkiformchavesecreta,armazenaresultadodeerroformuláriofica,comentáriomantido/_serverFncontext.userdadosJSONsem usuárioComo uma server function do TanStack Start adiciona o usuário e a página a uma resposta de feedbackFeedbackFormFormData + routeIduserMiddlewarelê o cookie de sessãovalidatornota, comentário,formato da telahandleradicionausuário epágina doRefererIkiformchave secreta,armazenaresultadode erroformuláriofica,comentáriomantido/_serverFncontext.userdadosJSONsem usuário
  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
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
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.

Prévia

Roda o validador do Ikiform no seu navegador. Nada é enviado.

Como esta tela está funcionando para você?

Definido pelo seu servidor a partir da requisição.

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

HostConfiguração
VercelAdicione nitro() de nitro/vite aos plugins do Vite, faça o deploy e adicione IKIFORM_SECRET_KEY às variáveis de ambiente do projeto.
NetlifyAdicione netlify() de @netlify/vite-plugin-tanstack-start e defina a chave nas variáveis de ambiente do site.
CloudflareAdicione 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

ProblemaO que fazer
As server functions perderam a verificação de CSRF depois que você adicionou src/start.tsSem 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 locaisO 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 CloudflareOs 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 unknownO 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_failedO 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.