Formulario de feedback para un sitio web o una app9 min de lectura
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.
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 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.
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.
- El usuario elige una nota y quizá escribe un comentario. El formulario los envía con el nombre de la pantalla.
- 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.
- getUser() lee y comprueba la cookie de sesión. Sin una sesión válida, no se envía nada.
- 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.
- 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.
Configuración del proyecto
Cuatro archivos, uno de los cuales probablemente ya tienes. El layout del App Router es el habitual:
app/
(app)/
projects/[id]/settings/page.tsx
feedback/
actions.ts
feedback-form.tsx
lib/
user.ts
.env.localCrea 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=the-secret-your-auth-already-usesEn 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.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
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.
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
"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.
pageFromconserva solo el origen y la ruta delReferer. Las query strings de una app suelen llevar ids y tokens que no quieres en una bandeja de feedback.screendebe cumplir un patrón corto con forma de ruta. Cualquier otra cosa se guarda comounknown, así que un envío manipulado no puede llenar la columna de basura.Originreenvía el origen del navegador. En producción es tu dominio; en tu máquina eslocalhost, que es lo que hace gratuitas las pruebas locales, como se explica más abajo.AbortSignal.timeoutevita que el botón se quede girando para siempre cuando la red va lenta.
El formulario
"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. pendingdeuseActionStatedesactiva 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.
BrokenyGreatdicen más que1y5, y el valor guardado sigue siendo un número.
Ponlo en una pantalla
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 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
- Ejecuta
next dev, inicia sesión, abre los ajustes de un proyecto y envía una nota de 2 con un comentario. - Abre Submissions. La respuesta tiene
user_id,email,screencon el valorprojects/[id]/settings, ypagecon el id real del proyecto y sin query string. - 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.
- Desactiva JavaScript y envía una vez más. La página se recarga con la línea de agradecimiento.
- 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
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.
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.
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.
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.