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

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

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

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

| Valor | Origen | Confianza |
| --- | --- | --- |
| `rating`, `comment` | El `FormData` del formulario | Las propias palabras del usuario |
| `user_id`, `email` | `context.user`, que pone el middleware de sesión | Se lee de una cookie sellada en el servidor |
| `page` | `getRequestHeader("referer")` dentro del handler | El navegador llama a la función desde la página abierta, así que la cabecera contiene su URL |
| `screen` | El `routeId` de la coincidencia más profunda, como `/_app/projects/$projectId/settings` | Una etiqueta para agrupar. Se comprueba con un patrón antes de guardarla |

Diagrama: Cómo una server function de TanStack Start añade el usuario y la página a una respuesta de feedback

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

```text
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`:

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

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

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

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

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

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

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

| Host | Configuración |
| --- | --- |
| Vercel | Añade `nitro()` de `nitro/vite` a los plugins de Vite, despliega y añade `IKIFORM_SECRET_KEY` a las variables de entorno del proyecto. |
| Netlify | Añade `netlify()` de `@netlify/vite-plugin-tanstack-start` y configura la clave en las variables de entorno del sitio. |
| Cloudflare | Añ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

| Problema | Qué hacer |
| --- | --- |
| Las server functions perdieron su comprobación CSRF al añadir `src/start.ts` | Sin `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 locales | La 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 Cloudflare | Los 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 `unknown` | El 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_failed` | El 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

### ¿Cómo obtengo el usuario actual dentro de una server function de TanStack Start?

Escribe un function middleware que lea la sesión y llame a next con el usuario en el contexto, y luego inclúyelo en el middleware de la server function. El handler lee context.user, tipado, en el servidor.

### ¿Puede una server function de TanStack Start leer las cabeceras de la petición?

Sí. getRequestHeader y getRequest de @tanstack/react-start/server leen la petición entrante dentro del handler, así que puedes tomar la página del Referer sin enviarla desde el navegador.

### ¿Funciona el formulario de feedback en el modo SPA de TanStack Start?

Sí, siempre que un servidor ejecute tus server functions y tu host no reescriba las rutas /_serverFn/ hacia la shell de la SPA.

### ¿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 React Router](https://ikiform.com/es/guides/feedback-form/react-router.md): 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.
- [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.
