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.
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:
| 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.
- Tras la hidratación, el componente detiene el envío nativo y envía los campos como JSON con $fetch.
- La server route comprueba el Origin, lee el usuario de la sesión, valida la nota y añade la página del Referer.
- Reenvía la respuesta a Ikiform con la clave secreta de runtimeConfig.
- Una petición JSON recibe { sent: true } o un error con un mensaje, y el formulario se actualiza en su sitio.
- Antes de la hidratación, el propio navegador envía el formulario. La ruta hace las mismas comprobaciones y el mismo envío.
- Luego responde con un 303 de vuelta a la página, con el resultado en la query string para que el componente lo muestre.
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.
app/
components/FeedbackForm.vue
pages/projects/[id]/settings.vue
server/
api/feedback.post.ts
shared/
types/auth.d.ts
nuxt.config.ts
.envDeclara 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.
export default defineNuxtConfig({
modules: ["nuxt-auth-utils"],
runtimeConfig: {
ikiformSecretKey: "",
},
});NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_SESSION_PASSWORD=at-least-32-characters-long-random-stringCrea 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:
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.
6 muestra el 422 que recibiría la ruta.Constrúyelo
La server route
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.tshace que el archivo solo responda aPOST. - La comprobación de Origin se ejecuta antes que nada.
readBody, que usareadValidatedBody, 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. getUserSessionno lanza excepciones. La ruta elige ella misma la respuesta: un401para$fetch, una redirección para un envío normal.requireUserSessionenviaría una página401a un usuario sin JavaScript.z.coerce.number()convierte el"4"urlencoded en4..catch("unknown")guarda unscreenmal formado comounknownen lugar de rechazar toda la respuesta.backconserva solo la ruta delReferer, así que una redirección nunca puede salir de tu sitio.
El componente
<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>methodyactionson 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.preventtoma el control.- El cuerpo JSON le indica a la ruta que puede responder con JSON. El cuerpo sale de
FormData, así que el honeypot yscreenviajan sin código extra. - Cuando falla un
$fetch, eldata.messagedel error es el mensaje decreateErroren 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. useUserSessionoculta 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
| 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
- 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. - En Submissions, comprueba que
user_idyemailcoinciden con tu cuenta, quescreenesprojects-id-settingsy quepagetiene la ruta real. - Desactiva JavaScript y vuelve a enviar. La página se recarga con la línea de agradecimiento y
?feedback=senten la dirección. - Envía a
/api/feedbackconcurly sinOrigin. La ruta responde403y nada llega a Ikiform. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.