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 in-app en TanStack Start

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.

Preet SutharFundador, Ikiform

Escribe una server function POST para el feedback y añádele un middleware de función que lea la sesión y ponga el usuario en context. La función añade a la nota ese usuario, la página del Referer de la petición y el id de la ruta activa, y luego lo reenvía a tu backend de formularios con una clave secreta. Monta el formulario una vez en tu ruta de layout con sesión iniciada y sabrá por sí solo en qué pantalla está.

La pestaña de TanStack Start de la guía principal envía feedback de página de cualquiera, con la página fijada en el navegador. Dentro de una app con sesión iniciada puedes hacerlo mejor: el servidor sabe quién pregunta y el router sabe qué pantalla está abierta. La configuración de la cuenta, las claves y los dominios permitidos se explican una vez en la guía de servidor de la documentación.

Quién aporta qué

En Start, una server function es un endpoint HTTP bajo /_serverFn/ al que tu componente llama mediante un cliente generado. Quien la llame puede reescribir cualquier cosa de su input. Así que el input lleva solo lo que eligió el usuario, y todo lo relativo a la identidad sale de un middleware que se ejecuta en el servidor antes del handler.

ValorOrigenConfianza
rating, commentEl FormData del formularioLas propias palabras del usuario
user_id, emailcontext.user, que pone el middleware de sesiónSe lee de una cookie sellada en el servidor
pagegetRequestHeader("referer") dentro del handlerEl navegador llama a la función desde la página abierta, así que la cabecera contiene su URL
screenEl routeId de la coincidencia más profunda, como /_app/projects/$projectId/settingsUna etiqueta para agrupar. Se comprueba con un patrón antes de guardarla
Cómo una server function de TanStack Start añade el usuario y la página a una respuesta de feedbackFeedbackFormFormData+routeIduserMiddlewareleelacookiedesesiónvalidadornota,comentario,nombredepantallahandlerañadeelusuarioy lapáginadelRefererIkiformclavesecreta,loguardaresultadodeerrorelformulariosigue,elcomentarioseconserva/_serverFncontext.userdatosJSONsin usuarioCómo una server function de TanStack Start añade el usuario y la página a una respuesta de feedbackFeedbackFormFormData + routeIduserMiddlewarelee la cookie desesiónvalidadornota, comentario,nombre de pantallahandlerañade elusuario y lapágina delRefererIkiformclave secreta, loguardaresultadode errorelformulariosigue, elcomentariose conserva/_serverFncontext.userdatosJSONsin usuario
  1. El formulario recoge la nota y el comentario, añade el id de la ruta actual y llama a la server function.
  2. En el servidor, el middleware lee la sesión y pasa el usuario como contexto.
  3. El validador convierte el FormData en un objeto tipado y descarta un nombre de pantalla con la forma incorrecta.
  4. El handler rechaza si falta el usuario, y luego añade el usuario y la página de la cabecera Referer.
  5. Envía a Ikiform con la clave secreta y devuelve sent, o un error que muestra el formulario.
El usuario sale de la cookie en el servidor. Nada de los datos del formulario puede cambiar de quién es una respuesta.

Configuración del proyecto

El árbol supone enrutado basado en archivos con un layout sin ruta _app que envuelve cada pantalla con sesión iniciada. session.ts es el helper de iron-session de la guía de autenticación de Start; cualquier librería de sesiones que lea una cookie funciona igual. findUser en users.ts y getCurrentUserFn en server/user.ts son tus propias búsquedas de usuario.

Árbol del proyecto
src/
  components/feedback-form.tsx
  lib/
    session.ts
    user-middleware.ts
    users.ts
  routes/
    _app.tsx
    _app/projects/$projectId/settings.tsx
  server/
    feedback.ts
    user.ts
.env

Start lee las variables sin prefijo de process.env solo en el servidor. Todo lo que lleva el prefijo VITE_ se empaqueta para el navegador, así que la clave nunca debe llevarlo. Léela dentro del handler, no al principio del módulo.

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=at-least-32-characters-from-your-auth-setup

Pega la definición de campos en la pestaña Fields del formulario. El formulario muestra dos; la server function rellena los otros cuatro, y el campo email hace que Reply en cada notificación vaya al usuario.

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 rating: 6 o un campo que la definición no incluya y la vista previa muestra el 422 que recibiría tu server function.

Constrúyelo

Middleware de sesión

Un function middleware envuelve cualquier server function que lo incluya en su lista. Lo que pases a next({ context }) llega tipado al handler. Pasa null en lugar de lanzar un error, para que el handler pueda responder con un mensaje que el formulario muestre.

src/lib/user-middleware.ts
import { createMiddleware } from "@tanstack/react-start";
import { getAppSession } from "./session";
import { findUser } from "./users";

export const userMiddleware = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const session = await getAppSession();
  const user = session.userId ? await findUser(session.userId) : null;
  return next({ context: { user: user ? { id: user.id, email: user.email } : null } });
});

La server function

src/server/feedback.ts
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { userMiddleware } from "../lib/user-middleware";

export type FeedbackResult = { status: "sent" } | { status: "error"; message: string };

const ROUTE_ID = /^\/[A-Za-z0-9/$_.-]{0,119}$/;

function pageFrom(referer: string | undefined) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export const sendFeedback = createServerFn({ method: "POST" })
  .middleware([userMiddleware])
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    const rating = Number(data.get("rating"));
    const screen = String(data.get("screen") ?? "");
    return {
      rating: Number.isInteger(rating) && rating >= 1 && rating <= 5 ? rating : null,
      comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
      screen: ROUTE_ID.test(screen) ? screen : "unknown",
      trap: String(data.get("_gotcha") ?? ""),
    };
  })
  .handler(async ({ data, context }): Promise<FeedbackResult> => {
    if (!context.user) return { status: "error", message: "You're signed out. Sign in and send it again." };
    if (data.rating === null) return { status: "error", message: "Choose a score first." };

    const origin = getRequestHeader("origin");
    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: origin } : {}),
        },
        body: JSON.stringify({
          rating: data.rating,
          comment: data.comment,
          page: pageFrom(getRequestHeader("referer")),
          screen: data.screen,
          user_id: context.user.id,
          email: context.user.email,
          _gotcha: data.trap,
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch {
      return { status: "error", message: "Feedback is unreachable right now. Try again shortly." };
    }

    const result = await response.json().catch(() => null);
    if (result?.ok) return { status: "sent" };
    return { status: "error", message: result?.error?.message ?? "That didn't go through. Try again." };
  });
  • .validator recibe el FormData sin procesar y devuelve un objeto plano. Una nota incorrecta se convierte en null en lugar de un error lanzado, que llegaría al cliente como un fallo genérico.
  • getRequestHeader lee la petición entrante desde cualquier parte del handler, sin ir pasando un objeto de petición.
  • Origin se reenvía desde la llamada del navegador. Decide si Ikiform trata el envío como una prueba local, como se explica más abajo.
  • En el contexto solo entran un id y un email, no el registro completo del usuario. Todo lo que devuelve el handler se serializa hacia el navegador.

El formulario

src/components/feedback-form.tsx
import { useMatches } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { type FeedbackResult, sendFeedback } from "../server/feedback";

const SCORES = ["Badly", "Poorly", "Okay", "Well", "Very well"];

export function FeedbackForm() {
  const send = useServerFn(sendFeedback);
  const screen = useMatches({ select: (matches) => matches.at(-1)?.routeId ?? "unknown" });
  const [result, setResult] = useState<FeedbackResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    data.set("screen", screen);
    setPending(true);
    try {
      setResult(await send({ data }));
    } catch {
      setResult({ status: "error", message: "We lost the connection. Try again." });
    } finally {
      setPending(false);
    }
  }

  if (result?.status === "sent") return <p role="status">Thanks. We read every one.</p>;

  return (
    <form method="post" onSubmit={onSubmit} aria-busy={pending}>
      <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={4} maxLength={4000} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {result?.status === "error" ? <p role="alert">{result.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
  • useMatches con select devuelve solo el routeId de la última coincidencia, así que el componente se vuelve a renderizar cuando cambia la pantalla, no con cada actualización del router.
  • onSubmit detiene el envío nativo y llama a la función. Nada reinicia el formulario, así que tras un error el comentario sigue en el cuadro.
  • pending desactiva el botón mientras dura la llamada, lo que evita un envío doble.

Móntalo en la ruta de layout

src/routes/_app.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { FeedbackForm } from "../components/feedback-form";
import { getCurrentUserFn } from "../server/user";

export const Route = createFileRoute("/_app")({
  beforeLoad: async () => {
    const user = await getCurrentUserFn();
    if (!user) throw redirect({ to: "/login" });
    return { user };
  },
  component: () => (
    <>
      <Outlet />
      <aside>
        <FeedbackForm />
      </aside>
    </>
  ),
});

beforeLoad mantiene a los usuarios sin sesión fuera de las pantallas, pero no es lo que protege el feedback: cualquiera puede llamar a /_serverFn/ directamente, y por eso el middleware vuelve a comprobarlo. Los ids de ruta incluyen los layouts sin ruta, de ahí el prefijo /_app en screen. Quítalo antes de guardarlo si prefieres nombres más cortos.

Honeypot, verificación humana y límites

El input _gotcha pasa por el validador sin cambios, e Ikiform archiva como spam uno relleno. Su verificación humana se omite en los envíos hechos con una clave secreta, lo cual está bien detrás de una sesión: los bots no tienen una. Lo que queda es una cuenta con sesión iniciada enviando en bucle, e Ikiform cuenta todos esos envíos contra la dirección 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

El formulario funciona igual con SSR completo o con spa: { enabled: true } en las opciones de tanstackStart(). En modo SPA solo se prerenderiza la shell raíz y las pantallas se renderizan en el navegador, pero las server functions siguen necesitando un servidor en marcha: si un host reescribe todas las rutas hacia la shell, añade /_serverFn/* a las rutas que deja pasar. No prerenderices pantallas con sesión iniciada; en tiempo de build no hay usuario.

La documentación de Start también menciona una propiedad .url para llamar a una server function desde un formulario HTML normal. Esta página no la usa. Una pantalla con sesión iniciada ya está hidratada antes de que nadie escriba un comentario, y un envío sin script necesitaría que la función respondiera con una redirección en lugar de datos. Considera que el formulario necesita JavaScript.

Despliegue

HostConfiguración
VercelAñade nitro() de nitro/vite a los plugins de Vite, despliega y añade IKIFORM_SECRET_KEY a las variables de entorno del proyecto.
NetlifyAñade netlify() de @netlify/vite-plugin-tanstack-start y configura la clave en las variables de entorno del sitio.
CloudflareAñade cloudflare({ viteEnvironment: { name: "ssr" } }) de @cloudflare/vite-plugin, activa nodejs_compat y guarda la clave con wrangler secret put. Con una fecha de compatibilidad del 2025-04-01 o posterior, ese flag rellena process.env con tus secretos.

Pruebas gratis desde localhost

Sin plan, un espacio de trabajo acepta 50 envíos de prueba al mes cuando la cabecera Origin indica localhost. El fetch de tu propio servidor no tiene origen, así que un envío sin la cabecera reenviada recibe 402 plan_required y queda retenido. Con ella, vite dev en http://localhost:3000 transmite el origen del navegador y cada respuesta cuenta como prueba. En producción, la misma línea envía el dominio de tu app.

Comprueba que funciona

  1. Ejecuta vite dev, inicia sesión, abre los ajustes de un proyecto y envía un 2 con un comentario.
  2. En Submissions, la respuesta tiene user_id, email, screen con el id de ruta, y page con el id real del proyecto.
  3. Cierra sesión en otra pestaña y vuelve a enviar. El formulario muestra el mensaje de sesión cerrada y conserva el comentario.
  4. Llama a la función desde la consola del navegador con un id de usuario falsificado en los datos del formulario. El user_id guardado sigue siendo el de la sesión.
  5. Responde al email de notificación. La respuesta va al usuario con sesión iniciada.

Errores comunes

ProblemaQué hacer
Las server functions perdieron su comprobación CSRF al añadir src/start.tsSin src/start.ts, Start añade por su cuenta su middleware CSRF para las server functions. En cuanto defines uno, registra tú mismo createCsrfMiddleware ahí, filtrado a las server functions.
402 plan_required en las pruebas localesLa cabecera Origin no llegó a Ikiform. Revisa la línea que la reenvía y abre la app en localhost en lugar de una dirección de la red local.
IKIFORM_SECRET_KEY es undefined en CloudflareLos Workers solo rellenan process.env con nodejs_compat activado y una fecha de compatibilidad del 2025-04-01 o posterior. Revisa ambas cosas en wrangler.jsonc, o lee la clave de env en cloudflare:workers.
screen siempre es unknownEl id de ruta no pasó el patrón, a menudo por caracteres como { de parámetros opcionales. Amplía el patrón para que coincida con tus rutas.
422 validation_failedEl envío no coincide con la definición de campos del formulario. error.fields indica cada campo y el motivo. Haz que el name del input coincida con el id del campo.

Preguntas

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