Formulário de feedback para um site ou app8 min de leitura
Crie um formulário de feedback dentro do app com React Router
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.
Monte um formulário de feedback no layout em volta das suas rotas logadas e envie-o com useFetcher para uma resource route. A action dela lê o usuário da sessão em cookie, pega a página do Referer e encaminha a avaliação ao seu backend de formulários com uma chave secreta. Quando o JavaScript ainda não carregou, a mesma action redireciona de volta para a página em vez de responder com JSON cru.
A aba React Router do guia principal envia um formulário de página pública por um fetcher. Aqui o formulário fica dentro do seu app, atrás do login, e a action decide quem o enviou. Para chaves, domínios permitidos e o básico, veja o guia de frameworks de servidor na documentação. O código é para o React Router 8 em modo framework.
O que um fetcher no layout muda
Um formulário de feedback num layout fica em todas as telas ao mesmo tempo, e o React Router o mantém montado enquanto o usuário navega. É isso que você quer, e isso levanta quatro questões que o guia principal nunca precisa responder:
| Questão | Num layout | Como esta página resolve |
|---|---|---|
| Quem enviou | O navegador poderia dizer que é qualquer usuário | A action lê a sessão assinada do cookie |
| Qual tela | O formulário não sabe qual rota filha está aparecendo | Cada rota dá nome a si mesma em handle, lido com useMatches |
| Estado que sobra | O data de um fetcher sobrevive à navegação | Um fetcher por tela pela opção key |
| Ainda sem JavaScript | fetcher.Form é um formulário de verdade, então o navegador o envia para a resource route | A action detecta um envio de documento e redireciona de volta |
- O usuário avalia a tela no formulário que o layout renderiza em toda rota logada.
- Depois da hidratação, o fetcher envia em segundo plano. A action lê a sessão do cookie, a página do Referer e o nome da tela.
- A action envia ao Ikiform com a chave secreta e o usuário anexado.
- O resultado volta por data() e aparece no lugar, sem navegação.
- Antes da hidratação, o navegador envia o formulário como documento. A action vê uma navegação e redireciona para a página de origem com o resultado na query.
Configuração do projeto
Os arquivos envolvidos, num projeto criado com create-react-router:
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsxAdicione uma resource route para a action e um layout sem caminho para as telas logadas. Um módulo com action e sem export default é uma resource route, então não renderiza nada.
import { layout, route, type RouteConfig } from "@react-router/dev/routes";
export default [
route("api/feedback", "routes/api.feedback.ts"),
layout("routes/app-layout.tsx", [route("projects/:id/settings", "routes/project-settings.tsx")]),
] satisfies RouteConfig;Crie uma chave secreta em API keys com o escopo submissions:write. O servidor lê os dois valores de process.env; defina-os onde o seu servidor roda e nunca dê à chave o prefixo VITE_, que a colocaria no bundle do cliente.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-stringCole a definição abaixo na aba Fields do formulário. A pessoa digita dois campos; a action fornece o resto, e o email da sessão faz o Reply de cada notificação chegar até ela.
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
rating igual a 9 ou um campo que a definição não lista, e você verá o 422 que a sua action recebe.Construa
Leia o usuário da sessão em cookie
createCookieSessionStorage assina o cookie com secrets, então um usuário consegue lê-lo, mas não alterá-lo. Se o seu login já grava uma sessão, reutilize o storage dela e adicione readUser.
import { createCookieSessionStorage } from "react-router";
interface SessionData {
userId: string;
email: string;
}
export const { getSession, commitSession, destroySession } = createCookieSessionStorage<SessionData>({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET ?? ""],
secure: process.env.NODE_ENV === "production",
},
});
export async function readUser(request: Request) {
const session = await getSession(request.headers.get("Cookie"));
const id = session.get("userId");
const email = session.get("email");
return id && email ? { id, email } : null;
}O sufixo .server mantém o módulo fora do bundle do cliente; importá-lo de um componente quebra o build.
A resource route
import { data, redirect } from "react-router";
import { readUser } from "~/sessions.server";
import type { Route } from "./+types/api.feedback";
const SCREEN = /^[a-z0-9/._-]{1,120}$/;
function samePage(referer: string | null, origin: string) {
try {
const url = new URL(referer ?? "");
return url.origin === origin ? url.pathname : "";
} catch {
return "";
}
}
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const origin = new URL(request.url).origin;
const path = samePage(request.headers.get("Referer"), origin);
const native = request.headers.get("Sec-Fetch-Mode") === "navigate";
function reply(status: "sent" | "error", message: string, code = 200) {
if (native) return redirect(`${path || "/"}?feedback=${status}`);
return data({ status, message }, { status: code });
}
const user = await readUser(request);
if (!user) return reply("error", "You were signed out. Sign in, then send it again.", 401);
const rating = Number(form.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return reply("error", "Choose a score first.", 400);
const screen = String(form.get("screen") ?? "");
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,
},
body: JSON.stringify({
rating,
comment: String(form.get("comment") ?? "").slice(0, 4000),
page: path ? origin + path : "",
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: user.id,
email: user.email,
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return reply("error", "Feedback is unreachable right now. Try again shortly.", 502);
}
const result = await response.json().catch(() => null);
if (result?.ok) return reply("sent", "Got it. Thank you.");
return reply("error", result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "That didn't go through.", 502);
}samePagemantém o caminho só quando oRefereré da sua própria origem, e descarta a query string, onde URLs de app carregam ids e tokens.Sec-Fetch-Modeénavigatequando o próprio navegador envia o formulário como carregamento de página. Fetchers o enviam como requisição em segundo plano, então recebemdata(), e todo o resto recebe um redirect.- Os erros usam um
data()4xx ou 5xx em vez de um throw. Um erro lançado subiria até oErrorBoundarymais próximo e substituiria a tela em que o usuário estava trabalhando. Originé definido como a própria origem da requisição. Isso éhttp://localhost:5173noreact-router dev, e o seu domínio em produção.
O formulário
import { useFetcher, useMatches, useSearchParams } from "react-router";
import type { action } from "~/routes/api.feedback";
const SCORES = ["Blocked", "Painful", "Okay", "Smooth", "Excellent"];
function useScreen() {
const named = useMatches().flatMap((match) => {
const screen = (match.handle as { screen?: string } | undefined)?.screen;
return screen ? [screen] : [];
});
return named.at(-1) ?? "unknown";
}
export function FeedbackForm() {
const screen = useScreen();
const fetcher = useFetcher<typeof action>({ key: `feedback:${screen}` });
const [params] = useSearchParams();
const busy = fetcher.state !== "idle";
const fallback = params.get("feedback");
const sent = fetcher.data?.status === "sent" || fallback === "sent";
const error = fetcher.data?.status === "error" ? fetcher.data.message : fallback === "error" ? "That didn't go through." : "";
if (sent) return <p role="status">Got it. Thank you.</p>;
return (
<fetcher.Form method="post" action="/api/feedback" aria-busy={busy}>
<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={3} maxLength={4000} />
<input type="hidden" name="screen" value={screen} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={busy}>{busy ? "Sending" : "Send feedback"}</button>
</fetcher.Form>
);
}useScreenpercorre as rotas correspondentes e fica com ohandle.screenmais profundo, então uma rota aninhada pode se nomear com mais precisão que a rota pai.- A
keydo fetcher inclui a tela. Depois de um envio numa tela, a próxima tela mostra um formulário novo em vez do agradecimento anterior. fetcher.stateésubmittinge depoisloadingenquanto os loaders correspondentes revalidam. Os dois contam como ocupado, então o botão fica desativado até a resposta chegar.- Sem JavaScript, o redirect volta para a página com
?feedback=sent, euseSearchParamsmostra a mesma frase de agradecimento.
Monte uma vez
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";
export default function AppLayout() {
return (
<>
<Outlet />
<aside aria-label="Feedback">
<FeedbackForm />
</aside>
</>
);
}export const handle = { screen: "projects/settings" };
export default function ProjectSettings() {
return <h1>Project settings</h1>;
}A tela de configurações de todos os projetos aparece como projects/settings, então as médias são calculadas juntas; o caminho concreto com o id real continua em page.
Honeypot, verificação humana e limites
O input _gotcha passa intacto pela action, e um preenchido é armazenado como spam. A verificação humana do Ikiform é pulada em envios com chave secreta, e a verificação de sessão toma o lugar dela: um visitante sem cookie assinado não envia nada. O que sobra para vigiar é uma conta enviando em loop, e o Ikiform conta todos esses envios contra o IP 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
A action precisa de um servidor. Com ssr: false em react-router.config.ts, o build não tem servidor, e server actions não rodam; você voltaria a uma clientAction enviando do navegador sem identidade confiável. Pré-renderizar páginas de marketing não tem problema, porque a action é uma rota separada que continua rodando por requisição. Mantenha ssr: true na parte logada do app.
Deploy
| Host | O que fazer |
|---|---|
| Vercel | Instale @vercel/react-router e adicione vercelPreset() a presets em react-router.config.ts. Adicione as duas variáveis nas configurações do projeto. |
| Netlify | Adicione @netlify/vite-plugin-react-router aos plugins do Vite. As actions rodam como Netlify Functions; defina as duas variáveis nas configurações de ambiente do site. |
| Cloudflare | Use o template de React Router da Cloudflare com @cloudflare/vite-plugin. Leia bindings com import { env } from "cloudflare:workers"; no React Router 8, o context da action não traz mais cloudflare.env. Leia a chave ali e guarde-a como secret do Worker, com .dev.vars para rodar localmente. |
Testes gratuitos a partir do localhost
O Ikiform dá a um workspace sem plano 50 envios de teste gratuitos por mês a partir de localhost, julgados pelo header Origin. Um fetch da sua action não tem Origin a menos que você adicione um, e um envio sem origem é tratado como envio real de servidor: 402 plan_required, retido até você escolher um plano. Repassar a origem da requisição faz o react-router dev contar como teste local.
Confira se funciona
- Rode
react-router dev, faça login, abra as configurações de um projeto, escolha Painful e adicione um comentário. - Confira Submissions:
screenéprojects/settings,pagetraz o id real sem query string, euser_ideemailbatem com a sua conta. - Vá para outra tela. O formulário ali está vazio, sem mostrar o agradecimento anterior.
- Desligue o JavaScript e envie de novo. A página recarrega com
?feedback=sente a frase de agradecimento. - Apague o cookie de sessão e envie. O formulário mostra a mensagem de login.
Pegadinhas
| Problema | O que fazer |
|---|---|
| O navegador mostra JSON cru depois do envio | O envio chegou como navegação e a action devolveu data(). Confira o ramo de Sec-Fetch-Mode e se nada na frente do seu servidor remove esse header. |
402 plan_required ao testar localmente | O envio não tinha origem local. Mantenha o header Origin na action e abra o app em localhost. |
screen é sempre unknown | Nenhuma rota correspondente exporta um handle com screen, ou o valor tem maiúsculas ou espaços. Use nomes em minúsculas, parecidos com caminhos. |
| O error boundary substitui a página quando um envio falha | Algo na action lançou um erro. Devolva data() com um status para cada falha esperada. |
readUser devolve null logo depois do login | SESSION_SECRET é diferente entre o código que gravou o cookie e este, então a assinatura falha. Use um único módulo de storage para os dois. |
Perguntas
Sim. Dê ao fetcher.Form um action apontando para a resource route. Devolva data() da action para que o fetcher receba o resultado, e redirecione quando a requisição for uma navegação do navegador, para que um envio antes da hidratação não mostre JSON cru.
Leia a sessão do header Cookie com a função getSession do seu session storage e depois leia o id do usuário nela. Nunca confie num id de usuário enviado pelo formulário.
Não. Com ssr como false não há servidor para rodar a action. Você poderia enviar de uma clientAction, mas aí é o navegador que fornece a identidade, e ela não é confiável.
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.