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

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

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

Diagrama: Dois caminhos pelos quais um envio de feedback do React Router chega à mesma resource route

1. O usuário avalia a tela no formulário que o layout renderiza em toda rota logada.
2. 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.
3. A action envia ao Ikiform com a chave secreta e o usuário anexado.
4. O resultado volta por data() e aparece no lugar, sem navegação.
5. 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.

Uma action, dois tipos de chamador. Só uma navegação do navegador recebe redirect; chamadas de fetcher recebem dados.

## Configuração do projeto

Os arquivos envolvidos, num projeto criado com `create-react-router`:

`Árvore do projeto`:

```text
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx
```

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

`app/routes.ts`:

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

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-string
```

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

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

`app/sessions.server.ts`:

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

`app/routes/api.feedback.ts`:

```ts
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);
}
```

- `samePage` mantém o caminho só quando o `Referer` é da sua própria origem, e descarta a query string, onde URLs de app carregam ids e tokens.
- `Sec-Fetch-Mode` é `navigate` quando o próprio navegador envia o formulário como carregamento de página. Fetchers o enviam como requisição em segundo plano, então recebem `data()`, 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é o `ErrorBoundary` mais 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:5173` no `react-router dev`, e o seu domínio em produção.

### O formulário

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

```tsx
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>
  );
}
```

- `useScreen` percorre as rotas correspondentes e fica com o `handle.screen` mais profundo, então uma rota aninhada pode se nomear com mais precisão que a rota pai.
- A `key` do 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` é `submitting` e depois `loading` enquanto 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`, e `useSearchParams` mostra a mesma frase de agradecimento.

### Monte uma vez

`app/routes/app-layout.tsx`:

```tsx
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";

export default function AppLayout() {
  return (
    <>
      <Outlet />
      <aside aria-label="Feedback">
        <FeedbackForm />
      </aside>
    </>
  );
}
```

`app/routes/project-settings.tsx`:

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

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

1. Rode `react-router dev`, faça login, abra as configurações de um projeto, escolha **Painful** e adicione um comentário.
2. Confira **Submissions**: `screen` é `projects/settings`, `page` traz o id real sem query string, e `user_id` e `email` batem com a sua conta.
3. Vá para outra tela. O formulário ali está vazio, sem mostrar o agradecimento anterior.
4. Desligue o JavaScript e envie de novo. A página recarrega com `?feedback=sent` e a frase de agradecimento.
5. 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

### Um formulário com useFetcher pode enviar para uma resource route?

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.

### Como pegar o usuário logado numa action do React Router?

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.

### Isso funciona no modo SPA do React Router?

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.

### 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 em TanStack Start](https://ikiform.com/pt-br/guides/feedback-form/tanstack-start.md): 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.
- [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.
