Guías18 min de lectura
Añade un formulario de feedback a tu sitio web o app
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.
Añade un formulario corto con una valoración del 1 al 5, un comentario opcional y un email opcional, y envíalo a un backend de formularios para no tener que mantener un servidor. Manda la dirección de la página con cada respuesta, porque el navegador no te dará la URL completa por sí solo. Lleva las notas bajas al chat de tu equipo el mismo día y guarda todas las respuestas en un solo sitio para ver tendencias.
La mayoría de la gente nunca te dice qué falla en tu sitio. Simplemente se va. Un pequeño formulario de feedback atrapa a los pocos que sí lo dirán, en el momento en que lo notan, en la página que lo causó. Esta guía construye uno que encaja en cualquier sitio o app, envía la página de la que viene, deja fuera a los bots y pone las respuestas descontentas delante de tu equipo el mismo día.
Cómo funciona un formulario de feedback
El formulario envía a Ikiform. Ikiform valida la nota, revisa el envío en busca de spam, lo guarda, avisa a tu equipo por email y manda un webhook firmado. Desde el webhook, las notas bajas van a un canal de chat y todas las respuestas pueden ir a una hoja de cálculo o una base de datos.
- Un visitante elige una nota, quizá escribe un comentario, y el formulario lo envía con la dirección de la página.
- Ikiform comprueba que la nota sea un número entero del 1 al 5, pasa las comprobaciones antispam y guarda la respuesta.
- La respuesta aparece en el panel, donde puedes filtrarla y exportarla como CSV.
- Ikiform avisa a tu equipo por email. Si el visitante dejó un email, al responder le llega directamente a él.
- Ikiform manda un webhook submission.created firmado con los campos y los metadatos del navegador.
- Un pequeño relay publica solo las notas bajas en el chat de tu equipo, para que se lean hoy.
Formas de recoger feedback
Cada tipo de herramienta está pensado para un trabajo distinto. Elige según lo que necesitas ver cuando llega una respuesta, no por el aspecto del widget.
| Enfoque | Punto fuerte | Inconvenientes |
|---|---|---|
| Herramientas de feedback para webs, como Hotjar o Usersnap | Capturas, anotaciones y contexto de la página sin más código que una etiqueta script. | Un script de terceros en cada página, respuestas guardadas en su producto y precios fijados por sus planes. |
| El widget de un rastreador de errores, como Sentry User Feedback | Vincula un reporte al error y, con la reproducción de sesiones activada, a una grabación de lo que pasó. | Pensado para bugs, no para opiniones. Primero necesitas el SDK del rastreador en tu app. |
| Creadores de formularios, como Typeform, Tally o Google Forms | Formularios pulidos sin código. Buenos para una encuesta que envías por enlace. | Un iframe o una página aparte, así que la respuesta pierde la página a la que se refería salvo que la pases en la URL. |
| Tableros de feedback, como Canny | Votación pública y actualizaciones de estado sobre peticiones de funcionalidades. | Pensados para ideas, no para reacciones rápidas. Las respuestas son públicas por diseño. |
| Tu propia ruta de API y base de datos | Control total sobre los datos y cualquier lógica. | Tú construyes el filtro antispam, los límites de frecuencia, el email de aviso y la exportación, y necesitas un servidor. |
| Un backend de formularios, como Ikiform | Tu propio markup en cualquier sitio o app. Validación, filtro antispam, almacenamiento, email y webhooks firmados sin servidor. | Sin capturas, porque no se admite la subida de archivos, y sin votaciones ni reproducción de sesiones. |
Si necesitas capturas en cada reporte, una herramienta de feedback o un rastreador de errores justifica su script. Si quieres que el formulario se parezca a tu sitio, que las respuestas sean portables y decidir tú adónde van, usa un backend de formularios. El resto de esta guía hace eso.
El formulario
Bastan tres preguntas: una nota, un comentario y un email opcional. La nota es lo que la gente responde; el comentario es donde está el valor; el email es cómo cierras el círculo. Elige tu stack abajo; la elección se aplica a todos los ejemplos de código de estas guías.
Un bloque de markup y un script corto. Sin JavaScript, el formulario sigue enviándose y redirigiendo; con él, el visitante se queda en la página y ve el error del servidor junto al botón. El script rellena page con la dirección sin la query string, para que los tokens de las URLs nunca lleguen a tu bandeja.
<form id="feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<fieldset>
<legend>How useful was this page?</legend>
<label><input type="radio" name="rating" value="1" required /> 1</label>
<label><input type="radio" name="rating" value="2" /> 2</label>
<label><input type="radio" name="rating" value="3" /> 3</label>
<label><input type="radio" name="rating" value="4" /> 4</label>
<label><input type="radio" name="rating" value="5" /> 5</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send feedback</button>
<p id="feedback-status" role="status"></p>
</form>
<script>
const form = document.getElementById("feedback");
const status = document.getElementById("feedback-status");
form.elements.page.value = location.origin + location.pathname;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Thanks. The team reads every note." }));
return;
}
const fields = result.error.fields ?? {};
status.textContent = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
status.textContent = "Couldn't send it. Try again in a moment.";
}
button.disabled = false;
});
</script>La server action lee la cabecera Referer de su propio envío. El formulario y la action comparten origen, así que el navegador manda la URL completa de la página, y la action se queda solo con el origen y la ruta. No hace falta ningún input oculto.
"use server";
import { headers } from "next/headers";
export interface FeedbackState {
sent: boolean;
error: string;
}
export async function sendFeedback(_previous: FeedbackState, formData: FormData): Promise<FeedbackState> {
const rating = Number(formData.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return { sent: false, error: "Pick a score from 1 to 5." };
const incoming = await headers();
const referer = incoming.get("referer");
const origin = incoming.get("origin");
const page = referer ? new URL(referer) : null;
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating,
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: page ? page.origin + page.pathname : "",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, error: "" };
const fields = result?.error?.fields ?? {};
return { sent: false, error: fields.comment?.[0] ?? fields.email?.[0] ?? "Couldn't send it. Try again." };
}"use client";
import { useActionState } from "react";
import { type FeedbackState, sendFeedback } from "./actions";
const initial: FeedbackState = { sent: false, error: "" };
export function FeedbackForm() {
const [state, action, pending] = useActionState(sendFeedback, initial);
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form action={action}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}¿Quieres profundizar con Next.js? Lee Crea un formulario de feedback in-app en Next.js.
La página conoce su propia dirección mientras se renderiza, así que Astro.url rellena el input oculto page en el servidor. Los inputs vacíos llegan a la action como null, que Ikiform trata como ausente. La página debe renderizarse bajo demanda para que se muestre el resultado de la action.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendFeedback: defineAction({
accept: "form",
input: z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().max(4000).nullish(),
email: z.email().nullish(),
page: z.string().nullish(),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { sent: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.sendFeedback);
const page = Astro.url.origin + Astro.url.pathname;
---
{result?.data?.sent ? (
<p role="status">Thanks. The team reads every note.</p>
) : (
<form method="POST" action={actions.sendFeedback}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label><input type="radio" name="rating" value={score} required /> {score}</label>
))}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000"></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="page" value={page} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Pick a score and check your email address." : result.error.message}</p>
)}
<button>Send feedback</button>
</form>
)}¿Quieres profundizar con Astro? Lee Crea un formulario de feedback in-app en Astro.
Una server function POST comprueba la nota antes de que nada salga de tu servidor. El componente toma page de window.location al enviar, así que es correcta incluso tras una navegación en el cliente.
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendFeedback = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const rating = Number(data.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("Pick a score from 1 to 5.");
return {
rating,
comment: String(data.get("comment") ?? ""),
email: String(data.get("email") ?? ""),
page: String(data.get("page") ?? ""),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
const fields = result.error.fields ?? {};
return { sent: false, error: String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) };
});
export function FeedbackForm() {
const send = useServerFn(sendFeedback);
const [state, setState] = useState({ sent: false, error: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setPending(true);
try {
setState(await send({ data }));
} catch (error) {
setState({ sent: false, error: error instanceof Error ? error.message : "Couldn't send it. Try again." });
} finally {
setPending(false);
}
}
if (state.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</form>
);
}¿Quieres profundizar con TanStack Start? Lee Crea un formulario de feedback in-app en TanStack Start.
Una resource route contiene la action, y useFetcher envía a ella desde cualquier página sin navegar. Registra la ruta como api/feedback en app/routes.ts. useLocation aporta la ruta, y la action la resuelve contra su propia URL, que tiene el origen de tu sitio.
import { data } from "react-router";
import type { Route } from "./+types/api.feedback";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const origin = request.headers.get("origin");
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: String(formData.get("rating") ?? ""),
comment: String(formData.get("comment") ?? ""),
email: String(formData.get("email") ?? ""),
page: new URL(String(formData.get("path") ?? "/"), request.url).href,
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
const fields = result.error.fields ?? {};
return data(
{ sent: false, error: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}import { useFetcher, useLocation } from "react-router";
import type { action } from "../routes/api.feedback";
export function FeedbackForm() {
const fetcher = useFetcher<typeof action>();
const { pathname } = useLocation();
const pending = fetcher.state !== "idle";
if (fetcher.data?.sent) return <p role="status">Thanks. The team reads every note.</p>;
return (
<fetcher.Form method="post" action="/api/feedback">
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input type="hidden" name="path" value={pathname} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{fetcher.data?.error ? <p role="alert">{fetcher.data.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send feedback"}</button>
</fetcher.Form>
);
}¿Quieres profundizar con React Router? Lee Crea un formulario de feedback dentro de la app con React Router.
Un endpoint +server.ts reenvía el envío, así que el componente puede estar en un layout y funcionar en todas las páginas. page de $app/state da la URL actual. Declara IKIFORM_SECRET_KEY en src/env.ts con defineEnvVars para que $app/env/private pueda leerla. El endpoint devuelve Response.json(), ya que SvelteKit 3 depreca el helper json().
import { error } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, fetch, url }) => {
const body = await request.json();
const rating = Number(body.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) error(400, "Pick a score from 1 to 5.");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
rating,
comment: String(body.comment ?? ""),
email: String(body.email ?? ""),
page: String(body.page ?? ""),
_gotcha: String(body._gotcha ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
error(response.status, String(fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message));
}
return Response.json({ sent: true });
};<script lang="ts">
import { page } from "$app/state";
let rating = $state(0);
let comment = $state("");
let email = $state("");
let trap = $state("");
let status = $state<"idle" | "sending" | "sent">("idle");
let message = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
status = "sending";
message = "";
const response = await fetch("/api/feedback", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ rating, comment, email, page: page.url.origin + page.url.pathname, _gotcha: trap }),
});
if (response.ok) {
status = "sent";
return;
}
message = (await response.json().catch(() => null))?.message ?? "Couldn't send it. Try again.";
status = "idle";
}
</script>
{#if status === "sent"}
<p role="status">Thanks. The team reads every note.</p>
{:else}
<form onsubmit={send}>
<fieldset>
<legend>How useful was this page?</legend>
{#each [1, 2, 3, 4, 5] as score}
<label><input type="radio" name="rating" value={score} bind:group={rating} required /> {score}</label>
{/each}
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" bind:value={comment}></textarea>
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" bind:value={email} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden bind:value={trap} />
{#if message}
<p role="alert">{message}</p>
{/if}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
{/if}¿Quieres profundizar con SvelteKit? Lee Crea un formulario de feedback dentro de la app en SvelteKit.
Una server route guarda la clave en runtimeConfig.ikiformSecretKey, que se rellena desde NUXT_IKIFORM_SECRET_KEY. useRoute().path y useRequestURL() construyen la dirección de la página tanto en el servidor como en el navegador.
export default defineEventHandler(async (event) => {
const body = await readBody<{ rating?: number; comment?: string; email?: string; page?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
rating: body.rating,
comment: body.comment ?? "",
email: body.email ?? "",
page: body.page ?? "",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
throw createError({
status: response.status,
message: String(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message),
});
}
return { sent: true };
});<script setup lang="ts">
const route = useRoute();
const requestUrl = useRequestURL();
const rating = ref<number | null>(null);
const comment = ref("");
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/feedback", {
method: "POST",
body: {
rating: rating.value,
comment: comment.value,
email: email.value,
page: requestUrl.origin + route.path,
_gotcha: trap.value,
},
});
status.value = "sent";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send it. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">What would make it better?</label>
<textarea id="comment" v-model="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" v-model="email" name="email" type="email" />
<input v-model="trap" 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>¿Quieres profundizar con Nuxt? Lee Crea un formulario de feedback dentro de la app en Nuxt.
Sin servidor propio, el navegador envía directamente a Ikiform. submit() de @ikiform/sdk manda el FormData y devuelve { ok } o los errores de campo. Nunca pongas aquí una clave secreta; Allowed domains (dominios permitidos) protege el formulario en su lugar.
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
export function FeedbackForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
data.set("page", window.location.origin + window.location.pathname);
setStatus("sending");
setError("");
const result = await submit(FORM_ID, data);
if (result.ok) {
setStatus("sent");
return;
}
const fields = result.error.fields ?? {};
setError(fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message);
setStatus("idle");
}
if (status === "sent") return <p role="status">Thanks. The team reads every note.</p>;
return (
<form onSubmit={onSubmit}>
<fieldset>
<legend>How useful was this page?</legend>
{[1, 2, 3, 4, 5].map((score) => (
<label key={score}>
<input type="radio" name="rating" value={score} required /> {score}
</label>
))}
</fieldset>
<label htmlFor="comment">What would make it better?</label>
<textarea id="comment" name="comment" rows={4} maxLength={4000} />
<label htmlFor="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send feedback"}</button>
</form>
);
}El mismo envío desde el navegador en un componente de un solo archivo. La etiqueta del comentario cambia cuando la nota es 1 o 2, lo que consigue respuestas más concretas de los visitantes descontentos.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const rating = ref(0);
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
body.set("page", window.location.origin + window.location.pathname);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "sent";
return;
}
const fields = result.error.fields ?? {};
error.value = fields.rating?.[0] ?? fields.comment?.[0] ?? fields.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't send it. Try again in a moment.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'sent'" role="status">Thanks. The team reads every note.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<fieldset>
<legend>How useful was this page?</legend>
<label v-for="score in 5" :key="score">
<input v-model="rating" type="radio" name="rating" :value="score" required /> {{ score }}
</label>
</fieldset>
<label for="comment">{{ rating && rating <= 2 ? "What went wrong?" : "What would make it better?" }}</label>
<textarea id="comment" name="comment" rows="4" maxlength="4000" />
<label for="feedback-email">Email, if you'd like a reply</label>
<input id="feedback-email" name="email" type="email" />
<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>- Sustituye
frm_xxxxxxxxxxxxxxxxpor el id de tu formulario, que está en el panel. ratinges un grupo de radios con cinco valores. Ikiform lo guarda como número, así que las medias no necesitan parseo.emailes opcional. La gente escribe con más sinceridad cuando no tiene que identificarse, y quien quiera una respuesta lo dejará.- El input oculto
_gotchaes el honeypot. Las personas nunca lo ven; los bots que rellenan todos los inputs se marcan solos como spam. pageregistra a qué página se refiere la respuesta. La siguiente sección explica por qué la envías tú.
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.Las guías de frameworks de la documentación cubren la configuración con más detalle: Next.js, Astro, SvelteKit, Nuxt, React, Vue y HTML puro. La plantilla de formulario de feedback genera los mismos campos en HTML, React y una server action; las plantillas de reporte de bugs y de encuesta cubren los formularios más largos.
Define los campos
Pega una definición en la pestaña Fields del formulario e Ikiform la aplica en cada envío, desde cualquier página. Pruébalo abajo: la vista previa ejecuta el validador de Ikiform en tu navegador, así que una nota de 0 o un email mal formado muestra el 422 que recibirías.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
rating acepta un número entero del 1 a su max, aquí 5. Puede llegar a 10, pero cinco opciones se responden más rápido.| Campo | Tipo | Por qué |
|---|---|---|
rating | rating, máx. 5 | Un clic. Se guarda como número, así que puedes promediarlo por página |
comment | textarea, hasta 4.000 caracteres | El motivo de la nota |
email | email, opcional | Responder a quien hace una pregunta. Pasa a ser el reply-to del aviso |
page | url, opcional | A qué página se refiere la respuesta |
Con los campos definidos, cualquier otro nombre en el envío se rechaza como Unknown field. Si más adelante envías más contexto, como una versión de la app o el nombre de un plan, añade antes un campo para ello. Los ids de campo van en snake_case en minúsculas.
Saber a qué página se refiere
Una nota sin página es difícil de aprovechar. Ikiform registra los metadatos del navegador con cada envío, pero uno de esos valores es menos útil de lo que parece:
| Valor | De dónde viene | Qué obtienes |
|---|---|---|
meta.user_agent | Lo registra Ikiform | El navegador y el sistema operativo, en un envío desde el navegador |
meta.origin | Lo registra Ikiform | El origen de tu sitio, como https://example.com |
meta.referrer | Lo registra Ikiform | Normalmente solo el origen. Los navegadores usan por defecto la política strict-origin-when-cross-origin, que quita la ruta al enviar a otro sitio |
meta.country | Lo registra Ikiform | Un código de país de dos letras |
page | Lo envía tu formulario | La ruta completa, para saber a qué página se refiere la respuesta |
Por eso todos los ejemplos de arriba envían page por su cuenta. Las versiones HTML y SPA leen location; las versiones de servidor leen la dirección que su framework ya conoce. Todas quitan la query string, que puede contener términos de búsqueda, ids de seguimiento o tokens de un solo uso que no quieres guardar.
meta.user_agent es el runtime de tu servidor, no el navegador del visitante. Para el feedback sobre páginas rara vez importa. Para los reportes de bugs sí, y la guía de reportes de bugs reenvía el user agent del navegador como un campo.Dónde preguntar
El sitio donde está el formulario decide lo que oyes. Un formulario en el pie de cada página recibe un goteo de opiniones fuertes. Una pregunta al final de una tarea recibe respuestas constantes y concretas.
| Ubicación | Bueno para | Cuidado con |
|---|---|---|
| Final de una página de documentación o un artículo | Encontrar páginas que no responden a la pregunta | Poco volumen por página. Léelo por sección, no por página |
| Tras una tarea en tu app, como una exportación o un primer proyecto | Cómo se siente un flujo concreto, mientras está reciente | Pregunta una vez por persona, no cada vez que terminan la tarea |
| Un botón flotante en cada pantalla | Cualquier cosa, en cualquier momento. Consulta el widget de React | No debe tapar tus propios botones, banners de cookies ni burbujas de chat |
| Un enlace en un email | Notas que quieres seguir en el tiempo, como el NPS | El cliente de email no es tu página, así que el formulario necesita una página de destino en tu sitio |
Mantén el formulario pequeño esté donde esté. Pide primero la nota y muestra el cuadro de comentario junto a ella, no en un segundo paso. Un segundo paso pierde a la mayoría, y una nota sin motivo sigue valiendo la pena.
Pon las notas bajas delante de las personas
- Todas las respuestas se guardan, sea cual sea su nota y la página de la que vengan.
- Ikiform manda un webhook submission.created firmado a tu relay.
- El relay lee la valoración. Las notas de 3 a 5 no necesitan nada más de momento.
- Una nota de 1 o 2 va al chat de tu equipo con la página y el comentario.
- Si el visitante dejó un email, alguien responde el mismo día.
- Una vez por semana, la exportación muestra las medias por página, que es donde aparecen las tendencias.
Los emails de aviso van a hasta 10 destinatarios confirmados por formulario y llegan con cada respuesta. Eso está bien para unas pocas respuestas al día. A partir de ahí, manda las notas bajas al chat con un relay como esta ruta de Next.js; el mismo código funciona en un Cloudflare Worker:
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const { rating, comment, page, email } = event.data.fields;
if (Number(rating) > 2) return new Response(null, { status: 204 });
const lines = [
`*${rating}/5* on ${page ?? event.data.meta.origin ?? "an unknown page"}`,
comment ? `> ${String(comment).slice(0, 500)}` : "_No comment._",
email ? `Reply to ${email}` : "Anonymous",
];
const response = await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: lines.join("\n") }),
});
if (!response.ok) return new Response(`Slack answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookcomprueba la firma y el timestamp, así que nadie más puede publicar en tu canal a través de esta URL.- Las notas por encima de 2 responden
204y se detienen. Ikiform considera entregado cualquier 2xx. - Una llamada fallida a Slack responde
502, e Ikiform reintenta con el mismowebhook-id, así que una caída breve no hace perder un aviso. - Los valores de los campos son lo que escribió el visitante. El relay recorta el comentario a 500 caracteres antes de que llegue al chat.
Añade el endpoint en Webhooks y copia su secreto whsec_ en IKIFORM_WEBHOOK_SECRET. Un espacio de trabajo puede tener 3 endpoints. Para rutas sin código, consulta Slack, Discord y Google Sheets. Para el feedback que debería convertirse en un ticket, Linear y GitHub Issues tiene un relay que crea uno por envío.
Deja fuera a los bots
Un formulario de feedback en cada página es un formulario público en cada página. Los bots lo encuentran, y casi todo lo que envían son enlaces. Los ajustes por defecto de Ikiform se ocupan de la mayor parte:
| Defensa | Qué frena | Configuración |
|---|---|---|
| Honeypot | Bots que rellenan todos los inputs | El input oculto _gotcha de los ejemplos |
| Puntuación de spam | Tres o más enlaces (+2), frases de spam conocidas (+2), emails desechables (+2), repeticiones idénticas (+3). Con 5 se guarda como spam | Ninguna |
| Límites de frecuencia | Scripts que envían en bucle | Activos por defecto: Standard, 5 por minuto por visitante y 300 por minuto para todo el formulario |
| Dominios permitidos | Envíos desde otros sitios | Añade tu dominio en los ajustes del formulario |
| Verificación humana | Bots que usan un navegador real | Pon Spam check en Ikiform. Los envíos con fetch desde el navegador necesitarán entonces un token de challenge() |
El spam recibe la misma respuesta de éxito que un envío real, no manda email ni webhook y no cuenta para el uso. Un visitante real que pegue tres enlaces en un comentario puede activar la regla de enlaces junto con una repetición, así que revisa de vez en cuando el filtro Spam. Marcar una respuesta como no spam envía entonces su webhook. La documentación antispam explica cada comprobación.
Lee las respuestas
Lee todos los comentarios las primeras semanas. Después, la vista útil es la nota media por página, de menor a mayor, con los comentarios al lado.
Exportar
Abre Submissions, elige el formulario de feedback, escoge un rango de fechas y pulsa Export CSV. Las columnas son id, created_at, form y status, y luego una por campo, así que con los campos de arriba rating es la columna E y page la columna H. Una exportación admite hasta 100.000 filas; la guía de exportación cubre la API para más.
Importa el archivo en Google Sheets y pon esto en una hoja vacía. Lista cada página con su nota media y el número de respuestas, de peor a mejor:
=QUERY(A2:H, "select H, avg(E), count(E) where D = 'received' and E is not null group by H order by avg(E) asc label avg(E) 'average', count(E) 'answers'", 0)- Ignora las páginas con solo una o dos respuestas. Un visitante enfadado no es una tendencia.
- Lee los comentarios de las tres páginas con peor nota. La solución suele estar en los primeros.
- Cuando cambies una página, compara su media de las semanas anteriores y posteriores, no la cifra histórica.
Elige tu caso
El formulario de arriba cubre reacciones rápidas en cualquier página. Estas páginas cubren feedback con otro propósito: un bug que reproducir, una petición que valorar, una nota que seguir o un cliente que se va.
- Formulario de reporte de bugsUn 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 funcionalidadesUn 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.
- Widget de feedback en ReactUn 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.
- Feedback de beta testersUn formulario de feedback para betas con un enlace personal por tester y build, dos escalas de valoración y un resumen semanal que muestra si cada build mejoró.
- Encuesta NPSHaz una encuesta NPS con una puntuación de 0 a 10 en el email, una pregunta de seguimiento en tu propia página que cambia según la puntuación y un script que calcula el NPS.
- Encuesta de cancelaciónPregunta por qué cancelan tus clientes dentro del flujo de baja de tu SaaS, deja Omitir a un clic, guarda el motivo en Stripe e Ikiform y nunca retrases la baja.
- Formulario de feedback en Next.jsUn 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 feedback en AstroUn 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 TanStack StartUn 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 feedback en React RouterUn 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 SvelteKitUn 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 NuxtUn 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.
Comprueba que funciona
Repasa esto antes de poner el formulario en todas las páginas. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde localhost. Para salir a producción, elige Pro desde 6 $/mes por 500 envíos, o paga una vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los precios.
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - Elige una nota, escribe un comentario y envía. La línea de agradecimiento sustituye al formulario, o el navegador llega a tu página de redirección en la versión de HTML puro.
- Abre Submissions en el panel. La respuesta muestra
ratingcomo número ypagecon la ruta completa. - Envía una nota de
6concurlo desde la consola del navegador. Ikiform responde422nombrandorating. - Revisa la bandeja de entrada de cada destinatario de las notificaciones. Cada uno recibe un email con los campos, y Reply va a quien envió el formulario cuando este tiene un campo
email. - Envía una nota de 1. Tu relay la publica en el chat del equipo, y la entrega en Webhooks muestra
succeeded. - Envía con el honeypot relleno,
_gotcha=bot. Igualmente recibe una respuesta de éxito, pero el envío se guarda como spam y no envía email ni webhook. - ¿No llegó nada? Cambia el filtro de estado del panel a Spam. El spam recibe la misma respuesta de éxito que un envío real.
Solución de problemas
| Problema | Qué hacer |
|---|---|
403 origin_not_allowed, o todos los envíos acaban en spam | El dominio de la página no está en Allowed domains del formulario. Añádelo, y localhost para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |
422 con Rating must be at most 5 | El formulario envía una nota mayor que el max del campo. Cambia los valores de los radios o sube max en la definición, hasta 10. |
422 con un Unknown field | El formulario envía un nombre que no está en la definición, a menudo un input oculto nuevo. Añade un campo con ese id o quita el input. |
page está vacío | El script que lo rellena no se ejecutó, o se ejecutó antes de que existiera el formulario. Ponlo después del formulario, o asigna page al enviar como hacen los ejemplos SPA. |
meta.referrer solo muestra tu dominio | Es lo esperado. Por defecto, los navegadores solo envían el origen en los envíos entre sitios. Usa el campo page en su lugar. |
| El envío está en el panel pero no llegó ningún email ni webhook | Se guardó como spam. El spam no envía nada. Ábrelo en el filtro Spam para ver los motivos y márcalo como no spam para enviar su email y sus webhooks. |
429 rate_limited | Un visitante envió más de lo que permite el límite de frecuencia del formulario. Standard permite 5 por minuto por visitante. Espera lo que indique Retry-After, o elige Relaxed o Custom para formularios que envían desde una IP compartida. |
402 test_limit_reached | El espacio de trabajo no tiene plan y ya usó sus 50 pruebas gratuitas desde localhost este mes. Elige un plan o usa Send test en el panel. |
Preguntas
Apunta el action del formulario a un endpoint de un backend de formularios. El formulario puede ser HTML puro en un sitio estático; el backend valida, guarda y te envía por email cada respuesta, y manda un webhook si la quieres en otro sitio.
Envía la dirección de la página como un campo. Ikiform también registra el referrer, pero los navegadores suelen enviar solo tu dominio en un envío entre sitios, así que un campo de página rellenado desde location es la forma fiable.
Haz que el email sea opcional. La gente es más franca sin él, y quien quiera respuesta lo dejará. Ikiform sigue registrando el navegador, el origen, el referrer y el país con cada respuesta.
No como archivo. Ikiform rechaza los envíos con archivos con 422 files_not_supported. Pide un enlace a una captura, o usa una herramienta de feedback basada en capturas si necesitas una en cada reporte.
No con un campo rating, que va del 1 a como mucho 10. Una nota del 0 al 10 usa un campo number con min 0, max 10 e integer true. La guía de NPS muestra la configuración completa.
Añade un webhook a un pequeño relay que compruebe la valoración y publique solo las notas bajas en Slack o Discord. Los emails de aviso cubren todas las respuestas; el relay cubre las que necesitan respuesta hoy.
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.