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

Preet SutharFundador, Ikiform

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:

ValorOrigenConfianza
rating, commentEl formularioLo que el usuario quiere decir
user_id, emailevent.locals.user, que fija el hook handleSe lee de la cookie de sesión en el servidor
pageEl Referer de la petición a la actionSolo del mismo origen; todo lo demás se descarta
screenpage.route.id en un input ocultoUna 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.

Cómo va un envío de feedback de SvelteKit desde un formulario del layout a través de una form actionformulariodellayoutcualquierpantalla,use:enhancehookhandlesesiónalocals.useraction?/sendcompruebausuarioynotaIkiformclavesecreta,loguardapage.formgraciasenlamismapantallafail(401o400)mensajebajoelformularioPOST /feedbacklocals+ usuario, páginanavigate: falsesin usuario o nota inválidaCómo va un envío de feedback de SvelteKit desde un formulario del layout a través de una form actionformulario dellayoutcualquier pantalla,use:enhancehook handlesesión a locals.useraction?/sendcompruebausuario y notaIkiformclave secreta, loguardapage.formgracias en la mismapantallafail(401o 400)mensaje bajoelformularioPOST /feedbacklocals+ usuario, páginanavigate: falsesin usuario o nota inválida
  1. El usuario puntúa la pantalla en el formulario del layout. use:enhance lo envía a /feedback?/send sin cargar la página.
  2. Primero se ejecuta el hook handle, que lee la cookie de sesión y pone el usuario en event.locals.
  3. La action send comprueba que hay un usuario y una nota entera de 1 a 5. Si no, devuelve fail con un mensaje.
  4. 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.
  5. update con navigate en false aplica el resultado a la pantalla actual, así que la línea de agradecimiento aparece donde está el usuario.
Sin JavaScript, el navegador llega en cambio a /feedback, que muestra el mismo resultado y un enlace de vuelta.

Configuración del proyecto

Árbol 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
.env

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

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
src/env.ts
import { 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.

¿Qué tal te funciona esta pantalla?

Lo pone tu servidor a partir de la petición.

Envía una nota de 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.

src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
src/hooks.server.ts
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

src/routes/feedback/+page.server.ts
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;
  • fail devuelve un estado y datos sin lanzar una excepción. En SvelteKit 3 un formulario mejorado también recibe ese estado, así que 401 y 400 aparecen en la pestaña de red como lo que son.
  • pageFrom conserva un Referer solo cuando es de tu propio origen, y descarta la query string.
  • export const prerender = false está 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 Origin transmite 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 como localhost, más abajo.
  • Un 429 de 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

src/lib/FeedbackForm.svelte
<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: false mantiene al usuario en su pantalla y pone el resultado aquí, en page.form.
  • refreshAll: false evita volver a ejecutar todas las funciones load tras 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.form pertenece a la página actual. Cuando el usuario navega, se vacía, y la siguiente pantalla muestra un formulario nuevo.
src/routes/(app)/+layout.svelte
<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:

src/routes/feedback/+page.svelte
<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().

HostAdaptador 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

  1. Ejecuta vite dev, inicia sesión, abre la pantalla de ajustes de un proyecto y envía una nota de 2 con un comentario.
  2. Abre Submissions. screen muestra /projects/[id]/settings, page tiene el id real del proyecto, y user_id y email coinciden con tu cuenta.
  3. Borra la cookie de sesión y vuelve a enviar. El formulario muestra el mensaje de inicio de sesión y conserva tu comentario.
  4. Desactiva JavaScript y envía. Llegas a /feedback con la línea de agradecimiento y un enlace de vuelta a la pantalla.
  5. Responde al email de notificación. La respuesta va al usuario con sesión iniciada.

Errores comunes

ProblemaQué hacer
El agradecimiento aparece en /feedback en lugar de en la pantalla actualEl 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 actionEl 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 tuyoSvelteKit 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 /feedbackUna página con actions no se puede prerenderizar. Mantén export const prerender = false en su +page.server.ts.
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.