Formulario de feedback para un sitio web o una app

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.

Preet SutharFundador, Ikiform

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:

ValorLo poneCómo
rating, commentEl usuarioValidado por el esquema Zod de la action
user_id, emailMiddlewarecontext.locals.user, a partir de la cookie de sesión en cada petición
pageLa peticiónLa cabecera Referer, que es la página donde está el formulario
screenEl renderizado de la páginaAstro.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.

Dos formas en que un mismo formulario de feedback de Astro llega a la misma actionsin JavaScriptcon el scriptPOST delformularioa la páginaactualmiddlewarelocals.userdesde lacookiesendFeedbackZod, luegofetchla páginasere-renderizagetActionResult()actions.sendFeedback()FormDatadelformulariomiddlewaremismacookie,mismousuariosendFeedbackmismohandlerformulariosustituido{ data } o {error }URL de la páginaresultadofetchJSONDos formas en que un mismo formulario de feedback de Astro llega a la misma actionsin JavaScriptcon el scriptPOST del formularioa la página actualmiddlewarelocals.user desde la cookiesendFeedbackZod, luego fetchla página se re-renderizagetActionResult()actions.sendFeedback()FormData del formulariomiddlewaremisma cookie, mismo usuariosendFeedbackmismo handlerformulario sustituido{ data } o { error }URL de la páginaresultadofetchJSON
  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
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
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.

¿Qué tal te funciona esta pantalla?

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

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
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
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}

La action

src/actions/index.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
---
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
---
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

HostAdapterEl secreto
Vercel@astrojs/vercelAñ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/netlifyAñádela en las variables de entorno del sitio. Las sesiones usan Netlify Blobs por defecto.
Cloudflare@astrojs/cloudflareEjecuta 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

ProblemaQué hacer
El formulario se envía pero la página no muestra ningún resultadoLa página está prerenderizada. Añade export const prerender = false o cambia a output: "server".
IKIFORM_SECRET_KEY is missing al empezar el buildLos 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 localesEl 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ónEl 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_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.