Formulario de feedback para un sitio web o una app

Formulario de feedback para un sitio web o una app9 min de lectura

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.

Preet SutharFundador, Ikiform

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 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. 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ónEn un layoutCómo lo resuelve esta página
Quién lo envióEl navegador podría decir que es cualquier usuarioLa action lee la sesión firmada de la cookie
Qué pantallaEl formulario no sabe qué ruta hija se está mostrandoCada ruta se nombra a sí misma en handle, que se lee con useMatches
Estado sobranteEl data de un fetcher sobrevive a la navegaciónUn fetcher por pantalla mediante la opción key
Aún sin JavaScriptfetcher.Form es un formulario real, así que el navegador lo envía a la resource routeLa action detecta un envío de documento y redirige de vuelta
Dos formas en que un envío de feedback de React Router llega a la misma resource routefetcher.Formen ellayout dela appactionapi/feedbacksesión,Referer,pantallaIkiformclavesecreta, loguardafetcher.datagracias oel mensajeredirigedevuelta?feedback=senten la mismapáginafetch+ usuario, páginadata()Sec-Fetch-Mode: navigateDos formas en que un envío de feedback de React Router llega a la misma resource routefetcher.Formen el layout de la appactionapi/feedbacksesión, Referer,pantallaIkiformclave secreta, loguardafetcher.datagracias o el mensajeredirigede vuelta?feedback=senten la mismapáginafetch+ usuario, páginadata()Sec-Fetch-Mode: navigate
  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
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
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
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.

Vista previa

Ejecuta el validador de Ikiform en tu navegador. No se envía nada.

¿Qué tal te funciona esta pantalla?

Lo pone tu servidor a partir de la petición.

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

HostQué hacer
VercelInstala @vercel/react-router y añade vercelPreset() a presets en react-router.config.ts. Añade ambas variables en los ajustes del proyecto.
NetlifyAñ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.
CloudflareUsa 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

ProblemaQué hacer
El navegador muestra JSON sin procesar después de enviarEl 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 localEl 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 unknownNinguna 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íoAlgo 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ónSESSION_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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.