Formulario de feedback para un sitio web o una app8 min de lectura
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.
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 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 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.
- Sin JavaScript, el navegador envía el formulario a la página actual, con la action indicada en la URL.
- El middleware se ejecuta primero en cada petición y pone al usuario con sesión iniciada en context.locals.
- La action valida la nota y la pantalla, añade el usuario y la página, y envía a Ikiform con el secreto.
- Astro vuelve a renderizar la página, y Astro.getActionResult() muestra la línea de agradecimiento o el error.
- Con JavaScript, el script llama a la misma action por fetch. El middleware y el handler se ejecutan exactamente igual.
- El script sustituye el formulario por la línea de agradecimiento, o escribe el error debajo, sin recargar.
Configuración del proyecto
src/
actions/index.ts
components/FeedbackForm.astro
middleware.ts
env.d.ts
pages/app/projects/[id]/settings.astro
astro.config.mjs
.envDeclara 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.
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:
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
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.
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();
});declare namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}La action
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,requestycookies, pero noredirect, así que una sesión ausente se convierte en un errorUNAUTHORIZEDque el formulario puede mostrar. .catch("unknown")enscreenconvierte una etiqueta falsificada enunknownen lugar de hacer fallar toda la respuesta.- Los inputs vacíos llegan a una action
formcomonull, y por esocommenty el honeypot sonnullish. - Tanto el envío del formulario como la llamada del script los envía la página, así que
Referercontiene su dirección en ambos casos.pageFromconserva el origen y la ruta. Originse reenvía para que las pruebas locales cuenten comolocalhost. Más sobre eso abajo.
El componente del formulario
---
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.isInputErrordistingue los fallos del esquema, que tienenfields, de los errores que lanzó el handler, como el mensaje de sesión.- El script importa
actionsdeastro:actionsy 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,
getActionContexten el middleware te permite guardar el resultado y redirigir.
La página
---
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 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
- Ejecuta
astro dev, inicia sesión, abre la página de ajustes de un proyecto y envía un 2 con un comentario. - En Submissions, comprueba que
screenes/app/projects/[id]/settings, quepagetiene el id real y queuser_idyemailson los tuyos. - Desactiva JavaScript y vuelve a enviar. La página se recarga con la línea de agradecimiento.
- Borra la cookie de sesión y envía. El formulario muestra el mensaje para iniciar sesión.
- 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
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.
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.
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.
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.