Formulario de feedback para un sitio web o una app9 min de lectura
Crea un formulario de feedback dentro de la app en SvelteKit
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.
Pon el formulario en el layout que envuelve tus pantallas con sesión iniciada y envíalo a una única action con nombre, /feedback?/send, con use:enhance. Un hook handle pone el usuario en event.locals, así que la action añade ella misma su id y su email y lee la página de la petición. El navegador solo envía la nota, el comentario y el id de ruta.
La pestaña de SvelteKit de la guía principal envía JSON a un endpoint +server.ts desde cualquier página, con sesión o sin ella. Esta página construye la versión para el interior de una app: una form action real que funciona antes de que cargue JavaScript, con la identidad del usuario tomada de la sesión en el servidor. Está escrita para SvelteKit 3 y Svelte 5. Las claves y los dominios permitidos se explican en la guía de SvelteKit de la documentación.
Por qué una sola action para todas las pantallas
Las form actions pertenecen a una página, pero un formulario de feedback vive en un layout y aparece en docenas de páginas. Copiar una action en cada +page.server.ts sería un lío. SvelteKit permite que un formulario llame a la action de otra ruta con una query string, así que cada pantalla envía a /feedback?/send y un solo archivo contiene la lógica. Cada valor sale entonces del lugar correcto:
| Valor | Origen | Confianza |
|---|---|---|
rating, comment | El formulario | Lo que el usuario quiere decir |
user_id, email | event.locals.user, que fija el hook handle | Se lee de la cookie de sesión en el servidor |
page | El Referer de la petición a la action | Solo del mismo origen; todo lo demás se descarta |
screen | page.route.id en un input oculto | Una etiqueta para agrupar. Se comprueba con un patrón antes de guardarla |
page.route.id es la ruta del archivo de la ruta, como /(app)/projects/[id]/settings. Todas las páginas de ajustes de proyecto la comparten, que es justo lo que quieres al promediar notas por pantalla. La action quita los segmentos de grupo como (app), porque son un detalle de la estructura de carpetas.
- El usuario puntúa la pantalla en el formulario del layout. use:enhance lo envía a /feedback?/send sin cargar la página.
- Primero se ejecuta el hook handle, que lee la cookie de sesión y pone el usuario en event.locals.
- La action send comprueba que hay un usuario y una nota entera de 1 a 5. Si no, devuelve fail con un mensaje.
- Añade el id y el email del usuario, la página del Referer y el id de ruta limpio, y envía con la clave secreta.
- update con navigate en false aplica el resultado a la pantalla actual, así que la línea de agradecimiento aparece donde está el usuario.
Configuración del proyecto
src/
app.d.ts
env.ts
hooks.server.ts
lib/
FeedbackForm.svelte
server/session.ts
routes/
(app)/+layout.svelte
feedback/
+page.server.ts
+page.svelte
.envCrea una clave secreta con el scope submissions:write en API keys. SvelteKit 3 declara las variables de entorno en src/env.ts. Las variables son privadas y se leen en tiempo de ejecución salvo que digas lo contrario, así que la clave nunca llega a un bundle del navegador y puede cambiar en tu host sin recompilar. .env la rellena durante el desarrollo.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyimport { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});Pega esta definición en la pestaña Fields del formulario. El formulario muestra la nota y el comentario; la action aporta 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 o un campo que la definición no incluya, y la vista previa muestra el 422 que la action convierte en un mensaje.Constrúyelo
Pon el usuario en locals
handle se ejecuta en cada petición que recibe el servidor, incluidos los envíos a form actions. Tipa el usuario una vez en app.d.ts y luego léelo de tu sesión en el hook. readSession representa lo que tu código de autenticación ya hace con la cookie.
declare global {
namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
}
export {};import type { Handle } from "@sveltejs/kit/hooks";
import { readSession } from "#lib/server/session.js";
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = await readSession(event.cookies.get("session"));
return resolve(event);
};La form action
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const prerender = false;
const SCREEN = /^[a-z0-9/[\]._-]{1,120}$/;
function pageFrom(referer: string | null, origin: string) {
try {
const url = new URL(referer ?? "");
return url.origin === origin ? url.origin + url.pathname : "";
} catch {
return "";
}
}
export const actions = {
send: async ({ request, locals, url }) => {
const data = await request.formData();
if (!locals.user) return fail(401, { message: "Your session ended. Sign in again, then send it." });
const rating = Number(data.get("rating"));
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return fail(400, { message: "Pick a score from 1 to 5." });
}
const screen = String(data.get("screen") ?? "").replace(/\/\([^)]+\)/g, "") || "/";
const back = pageFrom(request.headers.get("referer"), url.origin);
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 ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
rating,
comment: String(data.get("comment") ?? "").trim().slice(0, 4000),
page: back,
screen: SCREEN.test(screen) ? screen : "unknown",
user_id: locals.user.id,
email: locals.user.email,
_gotcha: String(data.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
});
} catch {
return fail(502, { message: "We couldn't send it. Try again in a moment." });
}
if (response.status === 429) return fail(429, { message: "Too many answers at once. Try again in a minute." });
const result = await response.json().catch(() => null);
if (!result?.ok) {
return fail(400, { message: result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "We couldn't send it." });
}
return { sent: true, back };
},
} satisfies Actions;faildevuelve un estado y datos sin lanzar una excepción. En SvelteKit 3 un formulario mejorado también recibe ese estado, así que401y400aparecen en la pestaña de red como lo que son.pageFromconserva unReferersolo cuando es de tu propio origen, y descarta la query string.export const prerender = falseestá ahí a propósito. Una página con actions no se puede prerenderizar, así que si tu layout raíz prerenderiza por defecto, el build falla sin esto.- La línea de
Origintransmite el origen del navegador, con el de la propia app como alternativa, y le dice a Ikiform de dónde vino la petición. Eso es lo que hace que las pruebas locales cuenten comolocalhost, más abajo. - Un
429de Ikiform tiene su propio mensaje, porque detrás de un servidor significa que el formulario en conjunto está ocupado, no este usuario.
El formulario del layout
<script lang="ts">
import { enhance } from "$app/forms";
import { page } from "$app/state";
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
let sending = $state(false);
</script>
{#if page.form?.sent}
<p role="status">Thanks. We read every one.</p>
{:else}
<form
method="POST"
action="/feedback?/send"
aria-busy={sending}
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ navigate: false, refreshAll: false });
sending = false;
};
}}
>
<fieldset>
<legend>How is this screen working for you?</legend>
{#each scores as label, index}
<label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
{/each}
</fieldset>
<label for="feedback-comment">What would make it better?</label>
<textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
<input type="hidden" name="screen" value={page.route.id ?? ""} />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if page.form?.message}
<p role="alert">{page.form.message}</p>
{/if}
<button disabled={sending}>{sending ? "Sending" : "Send feedback"}</button>
</form>
{/if}- Por defecto, un formulario mejorado cuya action está en otra página navega hasta ella tras el envío.
navigate: falsemantiene al usuario en su pantalla y pone el resultado aquí, enpage.form. refreshAll: falseevita volver a ejecutar todas las funcionesloadtras un éxito. El feedback no cambia nada de lo que muestra la página.- Si falla, el formulario no se reinicia, así que el comentario sigue en el cuadro junto al mensaje.
page.formpertenece a la página actual. Cuando el usuario navega, se vacía, y la siguiente pantalla muestra un formulario nuevo.
<script lang="ts">
import FeedbackForm from "#lib/FeedbackForm.svelte";
let { children } = $props();
</script>
<main>{@render children()}</main>
<aside>
<FeedbackForm />
</aside>Sin JavaScript, el mismo formulario sigue funcionando. El navegador envía a /feedback?/send y llega a /feedback, que renderiza el resultado y un enlace de vuelta a la página de origen:
<script lang="ts">
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
</script>
{#if form?.sent}
<p role="status">Thanks. We read every one.</p>
<a href={form.back || "/"}>Back to where you were</a>
{:else if form?.message}
<p role="alert">{form.message}</p>
<a href="/">Back to the app</a>
{:else}
<p>Send feedback from the panel on any screen of the app.</p>
{/if}Honeypot, verificación humana y límites
El input oculto _gotcha pasa sin cambios, e Ikiform guarda como spam uno relleno. La verificación humana de Ikiform se omite en los envíos con clave secreta. Aquí la action ya rechaza a cualquiera sin sesión, lo que deja fuera a los bots que frenaría una verificación humana. Lo que queda es una cuenta con sesión iniciada enviando en bucle, así que configura un preset de límite de frecuencia para un formulario que envía desde un único 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.
Renderizado y despliegue
Las pantallas alrededor del formulario pueden renderizarse en el servidor o prerenderizarse como prefieras; el formulario en sí no necesita nada al renderizar. Solo /feedback tiene que ejecutarse en un servidor. Los adaptadores se configuran en vite.config.ts, dentro del plugin sveltekit().
| Host | Adaptador y clave |
|---|---|
| Vercel | @sveltejs/adapter-vercel. Añade IKIFORM_SECRET_KEY en Environment Variables. El runtime edge ya no existe en SvelteKit 3, así que la action se ejecuta en una función de Node. |
| Netlify | @sveltejs/adapter-netlify. Configura la clave en las variables de entorno del sitio. |
| Cloudflare | @sveltejs/adapter-cloudflare con Wrangler 4.118 o posterior, su peer dependency. Guarda la clave como secreto del Worker, por ejemplo con wrangler secret put IKIFORM_SECRET_KEY, y léela con $app/env/private como arriba. |
Pruébalo gratis en localhost
Sin plan, un espacio de trabajo recibe 50 envíos de prueba al mes desde localhost. Ikiform lo decide por la cabecera Origin, y un fetch desde tu servidor no envía ninguna a menos que la pongas. Por eso la action pasa el Origin de la petición: con vite dev es http://localhost:5173, y el envío cuenta como prueba gratuita. Si lo quitas, las pruebas locales vuelven con 402 plan_required, retenidas hasta que elijas un plan.
Comprueba que funciona
- Ejecuta
vite dev, inicia sesión, abre la pantalla de ajustes de un proyecto y envía una nota de 2 con un comentario. - Abre Submissions.
screenmuestra/projects/[id]/settings,pagetiene el id real del proyecto, yuser_idyemailcoinciden con tu cuenta. - Borra la cookie de sesión y vuelve a enviar. El formulario muestra el mensaje de inicio de sesión y conserva tu comentario.
- Desactiva JavaScript y envía. Llegas a
/feedbackcon la línea de agradecimiento y un enlace de vuelta a la pantalla. - Responde al email de notificación. La respuesta va al usuario con sesión iniciada.
Errores comunes
| Problema | Qué hacer |
|---|---|
El agradecimiento aparece en /feedback en lugar de en la pantalla actual | El callback de enhance llama a update() sin navigate: false, o el formulario no está mejorado. Revisa el bloque use:enhance. |
locals.user siempre es null en la action | El hook lee otro nombre de cookie, o el path de la cookie no cubre /feedback. SvelteKit 3 pone / por defecto en las cookies nuevas, pero las cookies creadas por código antiguo pueden estar limitadas a una subruta. |
| Se rechaza un envío desde otro dominio tuyo | SvelteKit siempre comprueba el origen de los envíos de formularios. Añade ese origen a csrf.trustedOrigins en lugar de abrirlo a todo el mundo. |
El build falla con un error sobre el prerenderizado de /feedback | Una página con actions no se puede prerenderizar. Mantén export const prerender = false en su +page.server.ts. |
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
Sí. Pon como action del formulario la otra ruta más el nombre de la action, como /feedback?/send. Con use:enhance, pasa navigate: false a update para que el resultado se muestre en la página actual en lugar de navegar hasta allí.
Lee la sesión en el hook handle y fija event.locals.user. Todas las actions reciben locals, así que pueden adjuntar el id y el email del usuario sin fiarse de nada de lo que envíe el navegador.
Las páginas con form actions no se pueden prerenderizar, porque un servidor tiene que responder al POST. Pon prerender en false en la ruta que contiene la action.
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.