# Crea un formulario de feedback in-app en Astro

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

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

Escribe una action de Astro con `accept: "form"` que valide la nota con Zod, lea el usuario con sesión iniciada de `context.locals` y reenvíe la respuesta a tu backend de formularios con un secreto de `astro:env`. Renderiza el formulario con su `action` apuntando a la action, para que envíe sin JavaScript, y añade un pequeño script que llame a la misma action sin salir de la página cuando haya JavaScript. La página a la que se refiere la respuesta sale del `Referer` de la petición, y `Astro.routePattern` da nombre a la pantalla.

La [pestaña de Astro de la guía principal](https://ikiform.com/es/guides/feedback-form.md#form) pregunta a cualquier visitante lo útil que fue una página. Esta página pone el feedback dentro de una app de Astro con usuarios con sesión iniciada, donde el servidor debe decidir quién lo escribió y el formulario solo pide una nota y un comentario. Para las claves y la configuración del formulario, la [guía de Astro de la documentación](https://ikiform.com/es/docs/guides/astro.md) tiene los detalles.

## Quién rellena qué

Las actions de Astro son endpoints públicos, accesibles en una URL con el nombre de la action. Cualquiera puede llamar a `sendFeedback` con un cuerpo manipulado, así que la action no puede creerse un id de usuario que le pasen. Separa los valores según quién es de fiar para aportarlos:

| Valor | Lo pone | Cómo |
| --- | --- | --- |
| `rating`, `comment` | El usuario | Validado por el esquema Zod de la action |
| `user_id`, `email` | Middleware | `context.locals.user`, a partir de la cookie de sesión en cada petición |
| `page` | La petición | La cabecera `Referer`, que es la página donde está el formulario |
| `screen` | El renderizado de la página | `Astro.routePattern`, como `/app/projects/[id]/settings`, en un input oculto |

`routePattern` es el motivo por el que Astro resulta cómodo aquí. Da nombre al archivo que renderizó la página, así que la pantalla de ajustes de cada proyecto queda bajo una sola etiqueta mientras `page` conserva la dirección real. Viaja por el navegador, así que el esquema comprueba su forma y sustituye cualquier cosa rara por `unknown`.

Diagrama: Dos formas en que un mismo formulario de feedback de Astro llega a la misma action

1. Sin JavaScript, el navegador envía el formulario a la página actual, con la action indicada en la URL.
2. El middleware se ejecuta primero en cada petición y pone al usuario con sesión iniciada en context.locals.
3. La action valida la nota y la pantalla, añade el usuario y la página, y envía a Ikiform con el secreto.
4. Astro vuelve a renderizar la página, y Astro.getActionResult() muestra la línea de agradecimiento o el error.
5. Con JavaScript, el script llama a la misma action por fetch. El middleware y el handler se ejecutan exactamente igual.
6. El script sustituye el formulario por la línea de agradecimiento, o escribe el error debajo, sin recargar.

Un handler, dos transportes. Las dos peticiones salen de la página, así que ambas llevan la cookie de sesión y la página en Referer.

## Configuración del proyecto

`Árbol del proyecto`:

```text
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env
```

Declara el secreto en el esquema de `astro:env`. Así Astro se niega a arrancar sin él, tipa el import y lo mantiene fuera del bundle del cliente. Sirve cualquier adaptador de servidor; aquí se muestra Node.

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

Pon `IKIFORM_SECRET_KEY=ik_sk_...` en `.env` para trabajar en local. Crea la clave en **API keys** con el scope `submissions:write`. Después pega esta definición en la pestaña **Fields** del formulario:

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)

El esquema de la action replica estos límites, así que la mayoría de las entradas incorrectas se rechazan antes de gastar una petición.

## Constrúyelo

### Pon el usuario en locals

El middleware se ejecuta antes de cada página y cada action. Lee ahí tu sesión una vez, con lo que te dé tu código de auth, y todos los handlers reciben el resultado. `readSession` representa ese código. Las sesiones propias de Astro también sirven: en ese caso lee `context.session` en la action.

`src/middleware.ts`:

```ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
```

`src/env.d.ts`:

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

### La action

`src/actions/index.ts`:

```ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

function pageFrom(referer: string | null) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      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 ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
```

- El segundo argumento del handler es el contexto de la action. Tiene `locals`, `request` y `cookies`, pero no `redirect`, así que una sesión ausente se convierte en un error `UNAUTHORIZED` que el formulario puede mostrar.
- `.catch("unknown")` en `screen` convierte una etiqueta falsificada en `unknown` en lugar de hacer fallar toda la respuesta.
- Los inputs vacíos llegan a una action `form` como `null`, y por eso `comment` y el honeypot son `nullish`.
- Tanto el envío del formulario como la llamada del script los envía la página, así que `Referer` contiene su dirección en ambos casos. `pageFrom` conserva el origen y la ruta.
- `Origin` se reenvía para que las pruebas locales cuenten como `localhost`. Más sobre eso abajo.

### El componente del formulario

`src/components/FeedbackForm.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      ))}
    </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={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
```

- `action={actions.sendFeedback}` hace que el formulario funcione sin nada de JavaScript. Astro gestiona el envío y renderiza la página con el resultado.
- `isInputError` distingue los fallos del esquema, que tienen `fields`, de los errores que lanzó el handler, como el mensaje de sesión.
- El script importa `actions` de `astro:actions` y llama al mismo handler con los datos del formulario. Astro lo empaqueta, así que no hay archivo extra.
- Un envío sin JavaScript deja la página como resultado de un POST, y al recargar pide reenviar. Si te molesta, `getActionContext` en el middleware te permite guardar el resultado y redirigir.

### La página

`src/pages/app/projects/[id]/settings.astro`:

```astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>
```

## Modo de renderizado

Una action de formulario solo funciona en una página renderizada bajo demanda. Las páginas prerenderizadas se generan una vez, así que no hay petición a la que enviar. Con la salida estática por defecto, marca cada página de la app con `export const prerender = false`, o pon `output: "server"` si casi todo el sitio está tras un inicio de sesión. El middleware también se ejecuta en las páginas prerenderizadas durante el build, donde no hay cookie, así que ahí `locals.user` es `null`.

## Honeypot, verificación humana y límites

El input oculto `_gotcha` pasa tal cual, y uno relleno se guarda como spam. La verificación humana nunca se ejecuta en un envío con clave secreta, y el requisito de iniciar sesión hace ese trabajo. Astro comprueba por defecto el `Origin` de los envíos de formularios a páginas bajo demanda, así que otro sitio no puede enviar el formulario desde el navegador de un usuario.

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.

## Despliegue

| Host | Adapter | El secreto |
| --- | --- | --- |
| Vercel | `@astrojs/vercel` | Añade `IKIFORM_SECRET_KEY` en las variables de entorno del proyecto. Si usas sesiones de Astro, configura un driver de almacenamiento; Vercel no tiene uno por defecto. |
| Netlify | `@astrojs/netlify` | Añádela en las variables de entorno del sitio. Las sesiones usan Netlify Blobs por defecto. |
| Cloudflare | `@astrojs/cloudflare` | Ejecuta `npx wrangler secret put IKIFORM_SECRET_KEY` y ponla en `.dev.vars` en local. `astro:env/server` la lee, igual que `env` de `cloudflare:workers`. Las sesiones usan un binding KV llamado `SESSION`. |

Desde Astro 6, `Astro.locals.runtime.env` ya no existe en el adaptador de Cloudflare. El código que leía la clave de ahí necesita el import de `astro:env` de arriba.

## Pruebas locales gratis

Un espacio de trabajo sin plan recibe 50 envíos de prueba gratis al mes desde `localhost`, y Ikiform los reconoce por la cabecera `Origin`. El navegador envía `http://localhost:4321` a `astro dev`, pero el `fetch` de la action a Ikiform no envía origen. La línea de `Origin` reenvía el del navegador, así que las pruebas locales son gratis, y en producción la cabecera es tu propio dominio.

## Comprueba que funciona

1. Ejecuta `astro dev`, inicia sesión, abre la página de ajustes de un proyecto y envía un 2 con un comentario.
2. En **Submissions**, comprueba que `screen` es `/app/projects/[id]/settings`, que `page` tiene el id real y que `user_id` y `email` son los tuyos.
3. Desactiva JavaScript y vuelve a enviar. La página se recarga con la línea de agradecimiento.
4. Borra la cookie de sesión y envía. El formulario muestra el mensaje para iniciar sesión.
5. Responde al email de notificación. Va dirigido al usuario con sesión iniciada.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| El formulario se envía pero la página no muestra ningún resultado | La página está prerenderizada. Añade `export const prerender = false` o cambia a `output: "server"`. |
| `IKIFORM_SECRET_KEY is missing` al empezar el build | Los secretos se validan cada vez que se importa `astro:env/server`. Pasa la variable al build, o un valor provisional ahí y el real en tiempo de ejecución. |
| `402 plan_required` en las pruebas locales | El `Origin` reenviado está vacío o no es `localhost`. Abre la app en `localhost:4321` y conserva la línea de `Origin`. |
| `locals.user` siempre es `null` en producción | El nombre o el dominio de la cookie cambian entre entornos, o la página sigue prerenderizada. Registra `context.cookies.has("session")` en el middleware. |
| `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

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

Ponlo en context.locals en src/middleware.ts y luego lee context.locals.user desde el segundo argumento del handler. La otra vía son las sesiones de Astro, mediante context.session.

### ¿Funcionan las actions de Astro sin JavaScript?

Sí, en páginas renderizadas bajo demanda. Pon la action en el action del formulario y POST en su method, y luego lee el resultado con Astro.getActionResult() en la página.

### ¿Cómo sé de qué pantalla vino el feedback en Astro?

Renderiza Astro.routePattern en un input oculto. Da el patrón del archivo de ruta, como /app/projects/[id]/settings, así que las respuestas se agrupan por pantalla y no por id.

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