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

Publicado el 2026-10-25 · https://ikiform.com/es/guides/feedback-form/sveltekit

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](https://ikiform.com/es/guides/feedback-form.md#form) 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](https://ikiform.com/es/docs/guides/sveltekit.md).

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

Diagrama: Cómo va un envío de feedback de SvelteKit desde un formulario del layout a través de una form action

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`:

```text
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`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`src/env.ts`:

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

Formulario: In-app feedback

- **¿Qué tal te funciona esta pantalla?** (`rating`, rating, obligatorio)
- **¿Qué la haría mejor?** (`comment`, textarea)
- **Página** (`page`, url)
- **Pantalla** (`screen`, text)
- **Usuario** (`user_id`, text)
- **Email** (`email`, email)

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`:

```ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null;
    }
  }
}

export {};
```

`src/hooks.server.ts`:

```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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/es/docs/rate-limits.md) 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

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

| 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

### ¿Puede un formulario de SvelteKit llamar a una action de otra ruta?

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

### ¿Cómo obtengo el usuario con sesión iniciada dentro de una form action de SvelteKit?

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.

### ¿Por qué mi ruta de feedback rompe el build con prerenderizado?

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.

### ¿Ikiform es gratis?

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.

## Más guías

- [Formulario de feedback para un sitio web o una app](https://ikiform.com/es/guides/feedback-form.md): 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.
- [Formulario de feedback en Nuxt](https://ikiform.com/es/guides/feedback-form/nuxt.md): 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.
- [Formulario de feedback en Astro](https://ikiform.com/es/guides/feedback-form/astro.md): Un 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 Next.js](https://ikiform.com/es/guides/feedback-form/nextjs.md): 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.
- [Formulario de reporte de bugs](https://ikiform.com/es/guides/feedback-form/bug-report.md): Un 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 funcionalidades](https://ikiform.com/es/guides/feedback-form/feature-request.md): Un 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.
