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.
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.
| 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 |
- O formulário coleta a nota e o comentário, adiciona o id da rota atual e chama a server function.
- No servidor, o middleware lê a sessão e repassa o usuário como contexto.
- O validator transforma o FormData num objeto tipado e descarta um nome de tela com formato errado.
- O handler recusa um usuário ausente e depois adiciona o usuário e a página do header Referer.
- Ele envia ao Ikiform com a chave secreta e devolve sent, ou um erro que o formulário mostra.
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.
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
.envO 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setupCole 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.
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.
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
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." };
});.validatorrecebe oFormDatabruto e devolve um objeto simples. Uma nota inválida viranullem vez de um erro lançado, que chegaria ao cliente como uma falha genérica.getRequestHeaderlê 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
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>
);
}useMatchescomselectdevolve só orouteIdda última correspondência, então o componente renderiza de novo quando a tela muda, não a cada atualização do router.onSubmitimpede 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.pendingdesativa o botão durante a chamada, o que impede um envio duplicado.
Monte na rota de layout
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
| 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
- Rode
vite dev, faça login, abra as configurações de um projeto e envie um 2 com um comentário. - Em Submissions, a resposta tem
user_id,email,screencom o id da rota epagecom o id real do projeto. - 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.
- Chame a função pelo console do navegador com um id de usuário forjado nos dados do formulário. O
user_idarmazenado continua sendo o da sessão. - 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
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.
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.
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.
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.