# Crea un formulario de feedback dentro de la app en Nuxt

> Un formulario de feedback dentro de una app Nuxt: una server route que lee la sesión del usuario, toma la página del Referer y sigue funcionando cuando JavaScript no ha cargado.

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

Envía un formulario de dos preguntas a una server route de Nuxt en `server/api/feedback.post.ts`. La ruta lee el usuario con sesión iniciada con `getUserSession` de nuxt-auth-utils, toma la página del `Referer` de la petición, valida la nota con Zod y lo reenvía todo a tu backend de formularios con una clave secreta guardada en `runtimeConfig`. Con JavaScript, el formulario envía mediante `$fetch` y se queda en su sitio; sin él, la misma ruta redirige de vuelta con el resultado en la URL.

La [pestaña de Nuxt de la guía principal](https://ikiform.com/es/guides/feedback-form.md#form) crea feedback de página para cualquiera que visite. Dentro de una app Nuxt con cuentas, la tarea es más concreta y los datos son más ricos: el usuario ha iniciado sesión, el servidor sabe quién es y el router sabe en qué pantalla está. Esta página construye esa versión. Las claves, los dominios permitidos y el resto de la configuración están en la [guía de Nuxt de la documentación](https://ikiform.com/es/docs/guides/nuxt.md).

## Qué cambia una app Nuxt con sesión iniciada

En un formulario público, el navegador decide qué se guarda. Aquí, el navegador solo decide la nota y el comentario. Todo lo que dice quién y dónde lo añade la server route, donde nadie puede editarlo:

| Campo | Lo pone | Cómo |
| --- | --- | --- |
| `rating`, `comment` | El usuario | Botones de opción y un cuadro de texto |
| `user_id`, `email` | La server route | `getUserSession(event)` lee la cookie de sesión sellada |
| `page` | La server route | La cabecera `Referer`, que el navegador envía con la ruta completa en los envíos del mismo origen |
| `screen` | El componente, comprobado por la ruta | `useRoute().name`, como `projects-id-settings`, que Nuxt genera a partir de la ruta del archivo de la página |

Los nombres de ruta son una clave de agrupación gratuita en Nuxt. La página de ajustes de cada proyecto comparte `projects-id-settings`, así que puedes promediar notas por pantalla mientras `page` conserva la dirección exacta.

Diagrama: Cómo una sola server route de Nuxt gestiona el feedback con y sin JavaScript

1. Tras la hidratación, el componente detiene el envío nativo y envía los campos como JSON con $fetch.
2. La server route comprueba el Origin, lee el usuario de la sesión, valida la nota y añade la página del Referer.
3. Reenvía la respuesta a Ikiform con la clave secreta de runtimeConfig.
4. Una petición JSON recibe { sent: true } o un error con un mensaje, y el formulario se actualiza en su sitio.
5. Antes de la hidratación, el propio navegador envía el formulario. La ruta hace las mismas comprobaciones y el mismo envío.
6. Luego responde con un 303 de vuelta a la página, con el resultado en la query string para que el componente lo muestre.

La ruta distingue ambos casos por el content type de la petición. Los dos caminos guardan exactamente la misma respuesta.

## Configuración del proyecto

Esto usa la estructura de Nuxt 4, con el código de la app en `app/` y el código de servidor en la raíz. Instala `nuxt-auth-utils` y `zod`. Si tu app ya gestiona el inicio de sesión de otra forma, mantenla y lee el usuario de tu propia sesión en la ruta.

`Árbol del proyecto`:

```text
app/
  components/FeedbackForm.vue
  pages/projects/[id]/settings.vue
server/
  api/feedback.post.ts
shared/
  types/auth.d.ts
nuxt.config.ts
.env
```

Declara la clave secreta en `runtimeConfig` con un valor vacío por defecto. Nuxt la rellena en tiempo de ejecución desde `NUXT_IKIFORM_SECRET_KEY`, y como la clave no está bajo `public`, solo existe en el servidor.

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`.env`:

```bash
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-string
```

Crea la clave en **API keys** con el scope `submissions:write`. nuxt-auth-utils sella su cookie de sesión con `NUXT_SESSION_PASSWORD`, que debe tener al menos 32 caracteres; en desarrollo escribe una por ti en la primera ejecución. Indícale cómo es tu usuario para que `session.user.id` esté tipado:

`shared/types/auth.d.ts`:

```ts
declare module "#auth-utils" {
  interface User {
    id: string;
    email: string;
  }
}

export {};
```

Después pega la definición en la pestaña **Fields** del formulario. Los dos primeros campos son el formulario; la server route añade el resto, 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)

La vista previa ejecuta el validador de Ikiform, así que una nota de `6` muestra el `422` que recibiría la ruta.

## Constrúyelo

### La server route

`server/api/feedback.post.ts`:

```ts
import { z } from "zod";

const Feedback = z.object({
  rating: z.coerce.number().int().min(1).max(5),
  comment: z.string().trim().max(4000).default(""),
  screen: z.string().regex(/^[a-z0-9_-]{1,120}$/).catch("unknown"),
  _gotcha: z.string().default(""),
});

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

export default defineEventHandler(async (event) => {
  const own = getRequestURL(event, { xForwardedHost: true });
  if (getRequestHeader(event, "origin") !== own.origin) {
    throw createError({ status: 403, message: "Posts from other sites are refused." });
  }

  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json") ?? false;
  const referer = getRequestHeader(event, "referer");
  const back = (outcome: string) => {
    const target = new URL(referer ?? "/", own.origin);
    return sendRedirect(event, `${target.pathname}?feedback=${outcome}`, 303);
  };

  const session = await getUserSession(event);
  if (!session.user) {
    if (wantsJson) throw createError({ status: 401, message: "Your session ended. Sign in again, then send it." });
    return back("signed-out");
  }

  const parsed = await readValidatedBody(event, (body) => Feedback.safeParse(body));
  if (!parsed.success) {
    if (wantsJson) throw createError({ status: 422, message: "Pick a score from 1 to 5." });
    return back("invalid");
  }

  const { ikiformSecretKey } = useRuntimeConfig(event);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${ikiformSecretKey}`,
      Origin: own.origin,
    },
    body: JSON.stringify({
      ...parsed.data,
      page: pageFrom(referer),
      user_id: session.user.id,
      email: session.user.email,
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return wantsJson ? { sent: true } : back("sent");
  if (wantsJson) {
    throw createError({ status: 502, message: result?.error?.message ?? "We couldn't send it. Try again." });
  }
  return back("failed");
});
```

- El sufijo `.post.ts` hace que el archivo solo responda a `POST`.
- La comprobación de Origin se ejecuta antes que nada. `readBody`, que usa `readValidatedBody`, también lee cuerpos urlencoded, así que sin ella otro sitio podría enviar un formulario normal a esta ruta con la cookie de tu usuario adjunta.
- `getUserSession` no lanza excepciones. La ruta elige ella misma la respuesta: un `401` para `$fetch`, una redirección para un envío normal. `requireUserSession` enviaría una página `401` a un usuario sin JavaScript.
- `z.coerce.number()` convierte el `"4"` urlencoded en `4`. `.catch("unknown")` guarda un `screen` mal formado como `unknown` en lugar de rechazar toda la respuesta.
- `back` conserva solo la ruta del `Referer`, así que una redirección nunca puede salir de tu sitio.

### El componente

`app/components/FeedbackForm.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
  "signed-out": "Your session ended. Sign in again, then send it.",
  invalid: "Pick a score from 1 to 5.",
  failed: "We couldn't send it. Try again in a moment.",
};
const SCORES = ["Broken", "Frustrating", "Fine", "Good", "Great"];

const route = useRoute();
const { loggedIn } = useUserSession();
const screen = computed(() => String(route.name ?? "unknown"));
const outcome = String(route.query.feedback ?? "");
const status = ref<"idle" | "sending" | "sent">(outcome === "sent" ? "sent" : "idle");
const error = ref(MESSAGES[outcome] ?? "");

async function send(event: SubmitEvent) {
  const body = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement));
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/feedback", { method: "POST", body });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "We couldn't send it. Try again in a moment.";
    status.value = "idle";
  }
}
</script>

<template>
  <template v-if="loggedIn">
    <p v-if="status === 'sent'" role="status">Thanks. We read every one.</p>
    <form v-else method="post" action="/api/feedback" :aria-busy="status === 'sending'" @submit.prevent="send">
      <fieldset>
        <legend>How is this screen working for you?</legend>
        <label v-for="(label, index) in SCORES" :key="label">
          <input type="radio" name="rating" :value="index + 1" required /> {{ label }}
        </label>
      </fieldset>
      <label for="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000" />
      <input type="hidden" name="screen" :value="screen" />
      <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send feedback" }}</button>
    </form>
  </template>
</template>
```

- `method` y `action` son atributos reales, así que antes de la hidratación el navegador envía el formulario a la ruta. En cuanto Vue está en marcha, `@submit.prevent` toma el control.
- El cuerpo JSON le indica a la ruta que puede responder con JSON. El cuerpo sale de `FormData`, así que el honeypot y `screen` viajan sin código extra.
- Cuando falla un `$fetch`, el `data.message` del error es el mensaje de `createError` en la ruta.
- Tras un envío normal, `?feedback=` en la URL fija el estado inicial, así que el agradecimiento o el error aparecen en la página recargada.
- `useUserSession` oculta el formulario a los visitantes sin sesión. Es una comodidad, no una comprobación; quien comprueba es la ruta.

Pon `<FeedbackForm />` en cualquier página de la parte de tu app con sesión iniciada, como `app/pages/projects/[id]/settings.vue`. Nuxt lo importa automáticamente.

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

El input `_gotcha` llega a Ikiform con el resto de campos, y uno relleno se guarda como spam. La verificación humana de Ikiform se omite en los envíos con clave secreta, y aquí la sesión hace ese trabajo: la ruta rechaza a cualquiera que no haya iniciado sesión. Lo que queda es una cuenta con sesión iniciada enviando en bucle, que Ikiform solo puede contar por 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.

## Opciones de renderizado

El formulario necesita renderizado en el servidor para su resultado sin JavaScript. En una ruta con `ssr: false` en `routeRules`, la página está en blanco hasta que se ejecuta JavaScript, así que el envío normal nunca ocurre y `?feedback=` solo se lee en el navegador. No pasa nada, solo elimina la alternativa. No pongas el formulario en una ruta con `prerender: true`: el HTML se genera una sola vez, así que `useUserSession` no ve a nadie y el formulario no aparece hasta la hidratación.

## Despliegue

| Host | Qué hacer |
| --- | --- |
| Vercel | Nitro detecta Vercel, así que no hace falta preset. Añade `NUXT_IKIFORM_SECRET_KEY` y `NUXT_SESSION_PASSWORD` en **Environment Variables** y vuelve a desplegar. |
| Netlify | También se detecta. Añade ambas variables en los ajustes de entorno del sitio con el scope de Functions; la server route se ejecuta como función. |
| Cloudflare | Compila con el preset `cloudflare_module` para Workers, o conecta el repositorio a Pages, que se detecta. Guarda ambos valores como secretos y ponlos en `.dev.vars` para `wrangler dev`. |

En Cloudflare, los valores de entorno solo existen mientras se ejecuta una petición. La ruta lee `useRuntimeConfig(event)` dentro del handler, que es donde tiene que estar.

## Pruébalo gratis en localhost

Sin plan, un espacio de trabajo acepta 50 envíos de prueba al mes desde `localhost`, según la cabecera `Origin`. Un `fetch` desde una ruta de Nitro no tiene un navegador detrás y no envía `Origin` a menos que lo pongas. Por eso la ruta pasa `own.origin`: con `nuxt dev` es `http://localhost:3000` y cuenta como prueba, y en producción es tu dominio. Si lo quitas, todas las pruebas locales vuelven con `402 plan_required`.

## Comprueba que funciona

1. Ejecuta `nuxt dev`, inicia sesión, abre la página de ajustes de un proyecto y envía una nota de 2 con un comentario.
2. En **Submissions**, comprueba que `user_id` y `email` coinciden con tu cuenta, que `screen` es `projects-id-settings` y que `page` tiene la ruta real.
3. Desactiva JavaScript y vuelve a enviar. La página se recarga con la línea de agradecimiento y `?feedback=sent` en la dirección.
4. Envía a `/api/feedback` con `curl` y sin `Origin`. La ruta responde `403` y nada llega a Ikiform.
5. Borra la cookie de sesión y envía desde la página abierta. El formulario muestra el mensaje de inicio de sesión.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| `403` de tu propia ruta en producción | Un proxy cambia el host, así que `getRequestURL` no coincide con el `Origin` del navegador. Haz que el proxy envíe `X-Forwarded-Host`, que lee `xForwardedHost: true`. |
| `screen` es `unknown` en todas las respuestas | Los nombres de ruta con sufijo de idioma, como los de `@nuxtjs/i18n`, contienen caracteres que el patrón rechaza. Amplía la regex o quita el sufijo en el componente. |
| La clave secreta está vacía en producción | El nombre de la variable debe ser `NUXT_` más la clave en mayúsculas con guiones bajos, `NUXT_IKIFORM_SECRET_KEY`, y la clave debe existir en `runtimeConfig`. |
| `402 plan_required` en las pruebas locales | El envío salió sin `Origin`. Mantén la cabecera `Origin: own.origin` y abre la app en `localhost`. |
| `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 con sesión iniciada en una server route de Nuxt?

Con nuxt-auth-utils, llama a getUserSession(event) o requireUserSession(event) dentro del handler y lee session.user. Sin él, fija event.context.user en un server middleware y lee eso. Nunca tomes el usuario del cuerpo de la petición.

### ¿Puede funcionar un formulario de Nuxt sin JavaScript?

Sí. Dale al formulario un method y un action reales que apunten a una server route, y haz que la ruta redirija de vuelta con un 303 cuando la petición no sea JSON. Tras la hidratación, intercepta el submit y usa $fetch.

### ¿Cómo lee Nuxt las variables de entorno NUXT_?

Declara la clave en runtimeConfig en nuxt.config, y Nuxt la sobrescribe en tiempo de ejecución con una variable de entorno llamada NUXT_ más la clave en mayúsculas con guiones bajos. Las claves fuera de public se quedan en el servidor.

### ¿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 SvelteKit](https://ikiform.com/es/guides/feedback-form/sveltekit.md): Un formulario de feedback dentro de una app SvelteKit: un formulario en el layout envía a una única form action, que añade en el servidor el usuario desde locals y la página desde la petición.
- [Formulario de feedback en Astro](https://ikiform.com/es/guides/feedback-form/astro.md): Un formulario de feedback in-app en Astro: una action que toma el usuario de los locals del middleware, la página de la petición y la pantalla del patrón de ruta.
- [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.
- [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.
