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.
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ó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 |
- El usuario puntúa la pantalla en el formulario que el layout renderiza en cada ruta con sesión iniciada.
- 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.
- La action envía a Ikiform con la clave secreta y el usuario adjunto.
- El resultado vuelve mediante data() y se muestra en el sitio, sin navegación.
- 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.
Configuración del proyecto
Los archivos implicados, en un proyecto creado con create-react-router:
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsxAñ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.
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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-stringPega 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.
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.
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
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);
}samePageconserva la ruta solo cuando elRefereres tu propio origen, y descarta la query string, donde las URLs de una app llevan ids y tokens.Sec-Fetch-Modeesnavigatecuando 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 recibendata()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 elErrorBoundarymás cercano y sustituiría la pantalla en la que estaba trabajando el usuario. Originse fija con el propio origen de la petición. Eshttp://localhost:5173conreact-router dev, y tu dominio en producción.
El formulario
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>
);
}useScreenrecorre las rutas coincidentes y se queda con elhandle.screenmás profundo, así que una ruta anidada puede nombrarse con más precisión que su padre.- La
keydel fetcher incluye la pantalla. Tras un envío en una pantalla, la siguiente muestra un formulario nuevo en lugar del agradecimiento anterior. fetcher.stateessubmittingy luegoloadingmientras 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, yuseSearchParamsmuestra la misma línea de agradecimiento.
Móntalo una 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>;
}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
| 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
- Ejecuta
react-router dev, inicia sesión, abre los ajustes de un proyecto, elige Painful y añade un comentario. - Revisa Submissions:
screenesprojects/settings,pagetiene el id real sin query string, yuser_idyemailcoinciden con tu cuenta. - Pasa a otra pantalla. El formulario de ahí está vacío y no muestra el agradecimiento anterior.
- Desactiva JavaScript y vuelve a enviar. La página se recarga con
?feedback=senty la línea de agradecimiento. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.