# Crea un formulario de feedback dentro de la app con React Router

> Un formulario de feedback en React Router 8: la action de una resource route lee la sesión de la cookie, un fetcher envía desde el layout y un envío sin JS redirige de vuelta.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/feedback-form/react-router

Monta un único formulario de feedback en el layout que rodea tus rutas con sesión iniciada y envíalo con `useFetcher` a una resource route. Su action lee el usuario de tu sesión en cookie, toma la página del `Referer` y reenvía la nota a tu backend de formularios con una clave secreta. Cuando JavaScript aún no ha cargado, la misma action redirige de vuelta a la página en lugar de responder con JSON sin procesar.

La [pestaña de React Router de la guía principal](https://ikiform.com/es/guides/feedback-form.md#form) envía un formulario de una página pública mediante un fetcher. Aquí el formulario vive dentro de tu app, detrás del inicio de sesión, y la action decide quién lo envió. Para claves, dominios permitidos y lo básico, consulta la [guía de frameworks de servidor de la documentación](https://ikiform.com/es/docs/guides/server.md). El código está pensado para React Router 8 en modo framework.

## Qué cambia un fetcher en el layout

Un formulario de feedback en un layout está en todas las pantallas a la vez, y React Router lo mantiene montado mientras el usuario navega. Es lo que quieres, y plantea cuatro preguntas que la guía principal nunca tiene que responder:

| Cuestión | En un layout | Cómo lo resuelve esta página |
| --- | --- | --- |
| Quién lo envió | El navegador podría decir que es cualquier usuario | La action lee la sesión firmada de la cookie |
| Qué pantalla | El formulario no sabe qué ruta hija se está mostrando | Cada ruta se nombra a sí misma en `handle`, que se lee con `useMatches` |
| Estado sobrante | El `data` de un fetcher sobrevive a la navegación | Un fetcher por pantalla mediante la opción `key` |
| Aún sin JavaScript | `fetcher.Form` es un formulario real, así que el navegador lo envía a la resource route | La action detecta un envío de documento y redirige de vuelta |

Diagrama: Dos formas en que un envío de feedback de React Router llega a la misma resource route

1. El usuario puntúa la pantalla en el formulario que el layout renderiza en cada ruta con sesión iniciada.
2. Tras la hidratación, el fetcher envía en segundo plano. La action lee la sesión de la cookie, la página del Referer y el nombre de la pantalla.
3. La action envía a Ikiform con la clave secreta y el usuario adjunto.
4. El resultado vuelve mediante data() y se muestra en el sitio, sin navegación.
5. Antes de la hidratación, el navegador envía el formulario como documento. La action ve una navegación y redirige a la página de origen con el resultado en la query.

Una action, dos tipos de llamada. Solo una navegación del navegador recibe una redirección; las llamadas del fetcher reciben datos.

## Configuración del proyecto

Los archivos implicados, en un proyecto creado con `create-react-router`:

`Árbol del proyecto`:

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

Añade una resource route para la action y un layout sin ruta para las pantallas con sesión iniciada. Un módulo con una action y sin export por defecto es una resource route, así que no 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;
```

Crea una clave secreta en **API keys** con el scope `submissions:write`. El servidor lee ambos valores de `process.env`; configúralos donde se ejecute tu servidor y nunca le pongas a la clave el prefijo `VITE_`, que la metería en el bundle del cliente.

`.env`:

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

Pega la definición de abajo en la pestaña **Fields** del formulario. La persona escribe dos campos; la action aporta el resto, y el `email` de la sesión hace que **Reply** en cada notificación le llegue.

Formulario: In-app feedback

- **¿Qué tal te funciona esta pantalla?** (`rating`, rating, obligatorio)
- **¿Qué la haría mejor?** (`comment`, textarea)
- **Página** (`page`, url)
- **Pantalla** (`screen`, text)
- **Usuario** (`user_id`, text)
- **Email** (`email`, email)

Envía un `rating` de `9` o un campo que la definición no incluya, y verás el `422` que recibe tu action.

## Constrúyelo

### Lee el usuario de la sesión en cookie

`createCookieSessionStorage` firma la cookie con `secrets`, así que un usuario puede leerla pero no modificarla. Si tu login ya escribe una sesión, reutiliza su almacenamiento y añade `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;
}
```

El sufijo `.server` mantiene el módulo fuera del bundle del cliente; importarlo desde un componente hace fallar el build.

### La 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` conserva la ruta solo cuando el `Referer` es tu propio origen, y descarta la query string, donde las URLs de una app llevan ids y tokens.
- `Sec-Fetch-Mode` es `navigate` cuando el propio navegador envía el formulario como una carga de página. Los fetchers lo envían como petición en segundo plano, así que reciben `data()` y todos los demás reciben una redirección.
- Los errores usan un `data()` con 4xx o 5xx en lugar de lanzar una excepción. Un error lanzado subiría hasta el `ErrorBoundary` más cercano y sustituiría la pantalla en la que estaba trabajando el usuario.
- `Origin` se fija con el propio origen de la petición. Es `http://localhost:5173` con `react-router dev`, y tu dominio en producción.

### El formulario

`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` recorre las rutas coincidentes y se queda con el `handle.screen` más profundo, así que una ruta anidada puede nombrarse con más precisión que su padre.
- La `key` del fetcher incluye la pantalla. Tras un envío en una pantalla, la siguiente muestra un formulario nuevo en lugar del agradecimiento anterior.
- `fetcher.state` es `submitting` y luego `loading` mientras se revalidan los loaders coincidentes. Ambos cuentan como ocupado, así que el botón sigue desactivado hasta que llega la respuesta.
- Sin JavaScript, la redirección vuelve a la página con `?feedback=sent`, y `useSearchParams` muestra la misma línea de agradecimiento.

### Móntalo una 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>;
}
```

La pantalla de ajustes de cada proyecto se registra como `projects/settings`, así que sus medias se agrupan; la ruta concreta con el id real sigue en `page`.

## Honeypot, verificación humana y límites

El input `_gotcha` pasa por la action sin cambios, y uno relleno se guarda como spam. La verificación humana de Ikiform se omite en los envíos con clave secreta, y la comprobación de sesión ocupa su lugar: un visitante sin cookie firmada no consigue enviar nada. Lo que queda por vigilar es una cuenta enviando en bucle, e Ikiform cuenta todos esos envíos contra la IP de tu servidor.

Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

## SSR, modo SPA y prerenderizado

La action necesita un servidor. Con `ssr: false` en `react-router.config.ts`, el build no tiene servidor y las server actions no pueden ejecutarse; volverías a una `clientAction` que envía desde el navegador sin una identidad de confianza. Prerenderizar las páginas de marketing está bien, porque la action es una ruta aparte que sigue ejecutándose en cada petición. Mantén `ssr: true` para la parte de la app con sesión iniciada.

## Despliegue

| Host | Qué hacer |
| --- | --- |
| Vercel | Instala `@vercel/react-router` y añade `vercelPreset()` a `presets` en `react-router.config.ts`. Añade ambas variables en los ajustes del proyecto. |
| Netlify | Añade `@netlify/vite-plugin-react-router` a los plugins de Vite. Las actions se ejecutan como Netlify Functions; configura ambas variables en los ajustes de entorno del sitio. |
| Cloudflare | Usa la plantilla de React Router de Cloudflare con `@cloudflare/vite-plugin`. Lee los bindings con `import { env } from "cloudflare:workers"`; en React Router 8 el `context` de la action ya no lleva `cloudflare.env`. Lee la clave ahí y guárdala como secreto del Worker, con `.dev.vars` para las ejecuciones locales. |

## Pruebas gratis desde localhost

Ikiform da a un espacio de trabajo sin plan 50 envíos de prueba gratis al mes desde `localhost`, según la cabecera `Origin`. Un `fetch` desde tu action no lleva `Origin` a menos que lo añadas, y un envío sin origen se trata como un envío real de servidor: `402 plan_required`, retenido hasta que elijas un plan. Pasar el origen de la petición hace que `react-router dev` cuente como prueba local.

## Comprueba que funciona

1. Ejecuta `react-router dev`, inicia sesión, abre los ajustes de un proyecto, elige **Painful** y añade un comentario.
2. Revisa **Submissions**: `screen` es `projects/settings`, `page` tiene el id real sin query string, y `user_id` y `email` coinciden con tu cuenta.
3. Pasa a otra pantalla. El formulario de ahí está vacío y no muestra el agradecimiento anterior.
4. Desactiva JavaScript y vuelve a enviar. La página se recarga con `?feedback=sent` y la línea de agradecimiento.
5. Borra la cookie de sesión y envía. El formulario muestra el mensaje de inicio de sesión.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| El navegador muestra JSON sin procesar después de enviar | El envío llegó como navegación y la action devolvió `data()`. Revisa la rama de `Sec-Fetch-Mode` y que nada elimine esa cabecera delante de tu servidor. |
| `402 plan_required` al probar en local | El envío no tenía un origen local. Mantén la cabecera `Origin` en la action y abre la app en `localhost`. |
| `screen` siempre es `unknown` | Ninguna ruta coincidente exporta un `handle` con `screen`, o el valor tiene mayúsculas o espacios. Usa nombres en minúsculas y con forma de ruta. |
| El error boundary sustituye la página cuando falla un envío | Algo lanzó una excepción en la action. Devuelve `data()` con un estado para cada fallo esperado. |
| `readUser` devuelve null justo después de iniciar sesión | `SESSION_SECRET` es distinto entre el código que escribió la cookie y este, así que la firma falla. Usa un único módulo de almacenamiento para ambos. |

## Preguntas

### ¿Puede un formulario con useFetcher enviar a una resource route?

Sí. Dale a fetcher.Form un action que apunte a la resource route. Devuelve data() desde la action para que el fetcher reciba el resultado, y redirige cuando la petición sea una navegación del navegador para que un envío antes de la hidratación no muestre JSON sin procesar.

### ¿Cómo obtengo el usuario con sesión iniciada en una action de React Router?

Lee la sesión de la cabecera Cookie con la función getSession de tu almacenamiento de sesiones y luego lee de ella el id del usuario. Nunca confíes en un id de usuario enviado por el formulario.

### ¿Funciona esto en el modo SPA de React Router?

No. Con ssr en false no hay servidor que ejecute la action. Podrías enviar desde una clientAction, pero entonces la identidad la aporta el navegador y no es de fiar.

### ¿Ikiform es gratis?

No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.

## Más guías

- [Formulario de feedback para un sitio web o una app](https://ikiform.com/es/guides/feedback-form.md): Pon un formulario de feedback en cualquier página o dentro de tu app: una valoración, un comentario, la página de origen, filtro antispam y avisos cuando la nota es baja.
- [Formulario de feedback en TanStack Start](https://ikiform.com/es/guides/feedback-form/tanstack-start.md): Un formulario de feedback in-app en TanStack Start: una server function POST con middleware de sesión, el id de ruta como contexto y la página leída de las cabeceras.
- [Formulario de feedback en Next.js](https://ikiform.com/es/guides/feedback-form/nextjs.md): Un formulario de feedback in-app en Next.js: una server action que lee la sesión, toma la página de la petición y reenvía la nota con el usuario incluido.
- [Widget de feedback en React](https://ikiform.com/es/guides/feedback-form/react-feedback-widget.md): Un botón flotante de feedback para una app React que abre un panel pequeño, envía la pantalla, la versión de la app y la cuenta con cada respuesta, y nunca pierde un borrador.
- [Formulario de reporte de bugs](https://ikiform.com/es/guides/feedback-form/bug-report.md): Un formulario de reporte de bugs que pide a la gente los pasos y la gravedad, y deja que la página añada por su cuenta la URL, el navegador, el viewport y los errores recientes.
- [Formulario de peticiones de funcionalidades](https://ikiform.com/es/guides/feedback-form/feature-request.md): Un formulario de peticiones de funcionalidades que pregunta por el problema en lugar de la solución, guarda las peticiones por tema, cuenta personas y no filas, y les avisa por email cuando sale.
