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

Preet SutharFundador, Ikiform

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

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:

CampoLo poneCómo
rating, commentEl usuarioBotones de opción y un cuadro de texto
user_id, emailLa server routegetUserSession(event) lee la cookie de sesión sellada
pageLa server routeLa cabecera Referer, que el navegador envía con la ruta completa en los envíos del mismo origen
screenEl componente, comprobado por la rutauseRoute().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.

Cómo una sola server route de Nuxt gestiona el feedback con y sin JavaScriptCon JavaScriptSin JavaScriptFeedbackForm.vue$fetch,cuerpoJSONfeedback.post.tsorigen,sesión,ZodIkiformclavesecreta,lo guarda{ sent:true }elagradecimientosustituyealformularioenvíodeformularionormalcuerpourlencodedfeedback.post.tsmismascomprobacionesIkiformmismoenvío303 devuelta?feedback=sentPOST+ usuario, página200POST+ usuario, páginaredirecciónCómo una sola server route de Nuxt gestiona el feedback con y sin JavaScriptCon JavaScriptSin JavaScriptFeedbackForm.vue$fetch, cuerpo JSONfeedback.post.tsorigen, sesión, ZodIkiformclave secreta, lo guarda{ sent: true }el agradecimiento sustituye alformularioenvío de formularionormalcuerpo urlencodedfeedback.post.tsmismas comprobacionesIkiformmismo envío303 de vuelta?feedback=sentPOST+ usuario, página200POST+ usuario, páginaredirección
  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
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
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
.env
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
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.

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.

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

HostQué hacer
VercelNitro 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.
NetlifyTambié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.
CloudflareCompila 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

ProblemaQué hacer
403 de tu propia ruta en producciónUn 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 respuestasLos 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ónEl 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 localesEl envío salió sin Origin. Mantén la cabecera Origin: own.origin y abre la app en localhost.
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.