# Crea un formulario de feedback in-app en Next.js

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

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

Pon un pequeño formulario de cliente con una nota del 1 al 5 en las pantallas sobre las que quieras feedback, y apúntalo a una server action. La action lee el usuario que ha iniciado sesión desde tu cookie de sesión, toma la página del `Referer` de la petición y lo reenvía todo a tu backend de formularios con una clave secreta. El navegador nunca envía quién es el usuario, así que nadie puede mandar feedback haciéndose pasar por otra persona.

La [pestaña de Next.js de la guía principal](https://ikiform.com/es/guides/feedback-form.md#form) es un formulario de feedback de página para cualquier visitante. Esta página es para el interior de tu app, donde la gente ha iniciado sesión. Ahí ya sabes quién escribe y en qué pantalla está, así que el formulario hace dos preguntas y el servidor rellena el resto. La configuración del producto, las claves y los dominios permitidos están en la [guía de Next.js de la documentación](https://ikiform.com/es/docs/guides/nextjs.md).

## Qué cambia cuando el usuario ha iniciado sesión

Un formulario de feedback público acepta lo que le da el navegador. Un formulario in-app no debería. Si el navegador envía `user_id`, cualquiera puede cambiarlo en las herramientas de desarrollo y mandar feedback como un compañero o un cliente. Cada valor debe venir del sitio en el que se puede confiar para él:

| Valor | Viene de | Por qué ahí |
| --- | --- | --- |
| `rating`, `comment` | El formulario | Solo el usuario los conoce |
| `user_id`, `email` | La cookie de sesión, leída en la action | El navegador no puede falsificar una sesión firmada |
| `page` | La cabecera `Referer` de la petición de la action | Una server action hace POST a la página que la renderiza, así que la cabecera contiene la URL completa |
| `screen` | Una prop que pasa la página, como `projects/[id]/settings` | Agrupa la página de ajustes de cada proyecto bajo un mismo nombre, para que las medias tengan sentido |

`screen` es el único valor que pasa por el navegador. Es una etiqueta para agrupar, no un permiso, y la action comprueba su forma antes de guardarlo.

Diagrama: Cómo una respuesta de feedback en Next.js obtiene el usuario y la página en el servidor

1. El usuario elige una nota y quizá escribe un comentario. El formulario los envía con el nombre de la pantalla.
2. La server action se ejecuta como un POST a la página donde está el formulario, con la cookie de sesión y la página en Referer.
3. getUser() lee y comprueba la cookie de sesión. Sin una sesión válida, no se envía nada.
4. La action añade el id del usuario, su email y la dirección de la página, y envía a Ikiform con la clave secreta.
5. La respuesta de Ikiform se convierte en el estado de la action: la línea de agradecimiento, o el error con el comentario aún escrito.

La sesión se lee al enviar el formulario, no al renderizar la página. La página que rodea al formulario puede seguir siendo estática.

## Configuración del proyecto

Cuatro archivos, uno de los cuales probablemente ya tienes. El layout del App Router es el habitual:

`Árbol del proyecto`:

```text
app/
  (app)/
    projects/[id]/settings/page.tsx
  feedback/
    actions.ts
    feedback-form.tsx
lib/
  user.ts
.env.local
```

Crea una clave secreta en **API keys** con el scope `submissions:write` y ponla en `.env.local`. Nunca le pongas el prefijo `NEXT_PUBLIC_`, que la enviaría a todos los navegadores.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-uses
```

En la pestaña **Fields** del formulario, pega esta definición. Los usuarios ven los dos primeros campos; tu servidor pone los otros cuatro. Como hay un campo `email`, **Reply** en la notificación va directo 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)

Prueba una nota de `0` o un nombre de campo desconocido y la vista previa responde con el mismo `422` que recibe tu action.

## Constrúyelo

### Lee el usuario en un solo sitio

Next.js recomienda una capa de acceso a datos: una única función solo de servidor que lee y comprueba la sesión, usada por todas las actions. El `cache` de React hace que las llamadas repetidas en una misma petición lean la cookie una sola vez. `decrypt` es lo que ya tenga tu código de sesión; con una librería de auth, devuelve aquí su usuario.

`lib/user.ts`:

```ts
import "server-only";

import { cookies } from "next/headers";
import { cache } from "react";
import { decrypt } from "@/lib/session";

export interface User {
  id: string;
  email: string;
}

export const getUser = cache(async (): Promise<User | null> => {
  const token = (await cookies()).get("session")?.value;
  if (!token) return null;
  const session = await decrypt(token);
  return session?.userId ? { id: session.userId, email: session.email } : null;
});
```

### La server action

`app/feedback/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { getUser } from "@/lib/user";

export interface FeedbackState {
  status: "idle" | "sent" | "error";
  message: string;
  comment?: string;
}

const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;

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

export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
  const comment = String(formData.get("comment") ?? "").trim().slice(0, 4000);
  const user = await getUser();
  if (!user) return { status: "error", message: "Your session ended. Sign in again, then send it.", comment };

  const rating = Number(formData.get("rating"));
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
    return { status: "error", message: "Pick a score from 1 to 5.", comment };
  }

  const request = await headers();
  const screen = String(formData.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: request.get("origin") ?? "",
      },
      body: JSON.stringify({
        rating,
        comment,
        page: pageFrom(request.get("referer")),
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't send it. Try again in a moment.", comment };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "sent", message: "Thanks. We read every one." };
  return {
    status: "error",
    message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it. Try again.",
    comment,
  };
}
```

- La comprobación de sesión va primero y no lanza errores. Un usuario cuya sesión caducó a mitad de comentario recibe un mensaje y conserva el comentario, no una página de error.
- `pageFrom` conserva solo el origen y la ruta del `Referer`. Las query strings de una app suelen llevar ids y tokens que no quieres en una bandeja de feedback.
- `screen` debe cumplir un patrón corto con forma de ruta. Cualquier otra cosa se guarda como `unknown`, así que un envío manipulado no puede llenar la columna de basura.
- `Origin` reenvía el origen del navegador. En producción es tu dominio; en tu máquina es `localhost`, que es lo que hace gratuitas las pruebas locales, como se explica más abajo.
- `AbortSignal.timeout` evita que el botón se quede girando para siempre cuando la red va lenta.

### El formulario

`app/feedback/feedback-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";

const initial: FeedbackState = { status: "idle", message: "" };
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

export function FeedbackForm({ screen }: { screen: string }) {
  const [state, action, pending] = useActionState(sendFeedback, initial);

  if (state.status === "sent") return <p role="status">{state.message}</p>;

  return (
    <form action={action} 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} defaultValue={state.comment} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
    </form>
  );
}
```

- La action va directamente en `action`. Next.js la renderiza como un envío de formulario real, así que un usuario que hace clic antes de que cargue el JavaScript también envía su feedback.
- `pending` de `useActionState` desactiva el botón mientras se ejecuta la action, lo que evita envíos dobles.
- React reinicia el formulario después de una action. `defaultValue={state.comment}` devuelve el comentario cuando hay un error, para que nadie tenga que escribirlo dos veces.
- Las etiquetas dan nombre a las notas. `Broken` y `Great` dicen más que `1` y `5`, y el valor guardado sigue siendo un número.

### Ponlo en una pantalla

`app/(app)/projects/[id]/settings/page.tsx`:

```tsx
import { FeedbackForm } from "@/app/feedback/feedback-form";

export default function ProjectSettingsPage() {
  return (
    <main>
      <h1>Project settings</h1>
      <aside>
        <FeedbackForm screen="projects/[id]/settings" />
      </aside>
    </main>
  );
}
```

Pasa la ruta de la carpeta de la página como `screen`. Así la página de ajustes de cada proyecto comparte un mismo nombre, y la dirección completa con el id real sigue estando en `page`.

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

El input oculto `_gotcha` se reenvía tal cual. Ikiform guarda como spam uno que venga relleno. La verificación humana no se ejecuta, porque un envío con clave secreta se la salta, y aquí no pasa nada: la action rechaza a cualquiera sin sesión, y un bot no la tiene. El riesgo que queda es una cuenta con sesión iniciada enviando en bucle. El límite de frecuencia de Ikiform solo ve la dirección de tu servidor, así que elige un preset que encaje.

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.

## Renderizado y caché

El formulario no lee nada al renderizarse. No llama a `cookies()` ni a `headers()`, así que no vuelve dinámica la página, y con Cache Components activado puede quedarse en el shell estático sin un límite `<Suspense>`. Solo la action toca la petición, y las actions siempre se ejecutan por petición. Si la página ya lee la sesión por otros motivos, no cambia nada.

## Despliegue

| Host | Qué hacer |
| --- | --- |
| Vercel | Añade `IKIFORM_SECRET_KEY` en las **Environment Variables** del proyecto para Production y Preview. Las actions se ejecutan como funciones sin configuración extra. |
| Netlify | Netlify compila Next.js con su propia integración. Añade la clave en las variables de entorno del sitio con el scope Functions y vuelve a desplegar. |
| Cloudflare | Cloudflare ejecuta Next.js en Workers mediante su integración OpenNext. Guarda la clave como secreto del Worker y ponla en `.dev.vars` para las previews locales del build del Worker. |

Detrás de un proxy o una CDN que cambia el host, Next.js rechaza las actions cuyo `Origin` no coincide. Añade ese dominio a `experimental.serverActions.allowedOrigins` en `next.config.ts`.

## Pruébalo gratis en localhost

Un espacio de trabajo sin plan recibe 50 envíos de prueba al mes, pero solo desde `localhost`, que Ikiform lee de la cabecera `Origin`. Un `fetch` en el servidor no envía un `Origin` propio, así que sin la línea que lo reenvía, cada prueba local volvería con `402 plan_required` y quedaría retenida. Con ella, `next dev` en `http://localhost:3000` cuenta como prueba local, y los envíos de producción llevan tu dominio real.

## Comprueba que funciona

1. Ejecuta `next dev`, inicia sesión, abre los ajustes de un proyecto y envía una nota de 2 con un comentario.
2. Abre **Submissions**. La respuesta tiene `user_id`, `email`, `screen` con el valor `projects/[id]/settings`, y `page` con el id real del proyecto y sin query string.
3. Cierra la sesión en otra pestaña y vuelve a enviar. El formulario muestra el mensaje para iniciar sesión con tu comentario todavía en la caja.
4. Desactiva JavaScript y envía una vez más. La página se recarga con la línea de agradecimiento.
5. Responde al email de notificación. Va a la dirección del usuario que ha iniciado sesión.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| `402 plan_required` en todas las pruebas locales | El envío llegó a Ikiform sin un origen `localhost`. Revisa la línea de `Origin` en la action y abre la app en `localhost`, no en la dirección de red de tu máquina. |
| `page` está vacío | Una `Referrer-Policy` estricta como `no-referrer` elimina la cabecera, incluso en peticiones del mismo origen. Usa `strict-origin-when-cross-origin`, la opción por defecto. |
| Failed to find Server Action, justo después de un despliegue | Una pestaña sigue ejecutando el build anterior, cuyo id de action ya no existe. Pide al usuario que recargue, y mantén el mismo `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` en todas las instancias si lo alojas tú mismo. |
| `403 forbidden` de Ikiform | A la clave le falta `submissions:write`, pertenece a otro espacio de trabajo o se revocó. Crea una nueva. |
| `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 adjunto el usuario con sesión iniciada al feedback en Next.js?

Lee la sesión dentro de la server action, no en el navegador. Llama ahí a tu helper de sesión o de auth y añade el id y el email del usuario al envío. Todo lo que el navegador envíe sobre la identidad lo puede cambiar el usuario.

### ¿Cómo sabe una server action de Next.js de qué página vino el feedback?

Una server action es un POST a la página que renderiza el formulario, así que su cabecera Referer contiene la URL de esa página. Conserva el origen y la ruta y descarta la query string.

### ¿Un formulario de feedback vuelve dinámica mi página de Next.js?

Este no. El formulario no lee cookies ni cabeceras mientras se renderiza. Solo la action lee la sesión, y se ejecuta en cada envío.

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