wandres.dev
ACTIONS · funciones de servidor tipadas

Formularios HTML progresivos con Actions

Actions que funcionan sin una línea de JavaScript: enlazar un formulario con action={actions.miAccion}, leer el resultado en la página con Astro.getActionResult, y persistir ese estado a través de un redirect con getActionContext y la sesión, para lograr el patrón post-redirect-get que sobrevive a un refresco.

⏱ 17 min

La lección anterior llamaba a las actions con JavaScript, pero las actions tienen un talento que las distingue: funcionan también sin él. Un formulario HTML puede apuntar directamente a una action, y el navegador —solo con su envío nativo, sin islas ni fetch— disparará el handler en el servidor. Esta es la promesa de la mejora progresiva llevada al backend: la funcionalidad esencial vive en HTML puro y resiste aunque el JavaScript no cargue, falle o llegue tarde. Sobre esa base sólida, el JavaScript, cuando está, solo pule la experiencia.

🎯 Al terminar esta lección sabrás
  • Enlazar un formulario HTML con una action mediante action={actions.miAccion}.
  • Leer el resultado del envío en la propia página con Astro.getActionResult.
  • Pintar errores de validación campo a campo sin escribir JavaScript de cliente.
  • Persistir el estado de la action tras un redirect con getActionContext y la sesión.

El formulario que no necesita JavaScript

El puente entre un formulario y una action se tiende en el atributo action. En lugar de una cadena con una URL, le pasas la propia action, y Astro genera por debajo la ruta y los campos ocultos que hacen falta para enrutar el envío. El formulario ha de usar method="POST", y la action, aceptar formularios con accept: 'form', como vimos al hablar del input.

---
import { actions } from 'astro:actions';
---
<form method="POST" action={actions.suscribir}>
  <input type="email" name="email" required />
  <label><input type="checkbox" name="acepta" /> Acepto</label>
  <button type="submit">Suscribirme</button>
</form>

Sin una sola línea de JavaScript, este formulario funciona. Al pulsar el botón, el navegador hace su envío de siempre, Astro recibe el FormData, lo valida contra el input de suscribir, ejecuta el handler y vuelve a renderizar la página. Todo el ciclo ocurre en el servidor, con la mecánica que la web tiene desde su origen. El día que añadas una isla que intercepte el envío para evitar la recarga, el formulario mejorará; pero ese día es opcional, porque lo esencial ya funcionaba sin él.

📝
El mismo formulario, con o sin isla

La belleza del patrón es que no hay dos formularios, uno para JavaScript y otro para su ausencia. Es el mismo <form> con la misma action. Si no hay JavaScript, el navegador lo envía y la página se recarga con el resultado. Si lo hay, una isla puede interceptar el submit, llamar a la action con fetch y actualizar la interfaz sin recargar. La base es idéntica; la mejora es una capa que se añade encima sin reescribir nada.

Astro.getActionResult: leer el resultado en la página

Tras el envío sin JavaScript, la página se vuelve a renderizar, y necesitas una forma de saber qué pasó para mostrarlo. Esa forma es Astro.getActionResult, que en el frontmatter de la página recibe la action y devuelve su resultado { data, error } para la petición actual, o undefined si esta action no fue la que se envió.

---
import { actions, isInputError } from 'astro:actions';

const resultado = Astro.getActionResult(actions.suscribir);
const inputError = isInputError(resultado?.error) ? resultado.error : undefined;
---
<form method="POST" action={actions.suscribir}>
  <input type="email" name="email" required />
  {inputError?.fields.email && <p class="error">{inputError.fields.email}</p>}
  <button type="submit">Suscribirme</button>
</form>
{resultado?.data && <p class="ok">Listo, revisa tu correo</p>}

El resultado tiene la misma forma que en el cliente —el { data, error } de siempre— y las mismas herramientas para leerlo: isInputError para sacar los fields y pintar el fallo junto a cada campo, data para confirmar el éxito. La diferencia es dónde ocurre: todo en el servidor, dentro del frontmatter, sin un <script> a la vista. La validación que declaraste en el input se convierte, sin esfuerzo extra, en mensajes de error renderizados en el HTML que el navegador recibe ya pintado.

Un detalle de ergonomía cierra este patrón: cuando la validación falla, el usuario no debería perder lo que ya escribió. Como el resultado del envío está disponible en el servidor, puedes rellenar el atributo value de cada campo con lo que vino en la petición, de modo que el formulario se repinta con los datos previos y solo señala lo que hay que corregir. Sin una línea de JavaScript, un formulario que conserva lo tecleado y marca el error exacto ofrece una experiencia que durante años se creyó privativa de las aplicaciones cargadas de scripts.

Persistir el estado tras un redirect

El patrón anterior tiene un flanco conocido de los formularios que responden a un POST: si el usuario refresca la página, el navegador reenvía el formulario, repitiendo la operación. La cura clásica es el patrón post-redirect-get: tras procesar el envío, el servidor responde con un redirect a una página que se sirve con GET, de modo que el refresco recarga esa página inocua en vez de reenviar. Pero al redirigir se pierde el resultado de la action, y hay que persistirlo para poder mostrarlo después.

La pieza que lo permite es getActionContext, invocada desde el middleware. Te da información sobre la petición de action en curso —incluido si vino de un formulario o de una llamada del cliente— y las herramientas para capturar su resultado, serializarlo y guardarlo donde sobreviva al redirect, típicamente la sesión.

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';

export const onRequest = defineMiddleware(async (context, next) => {
  const { action, setActionResult, serializeActionResult } =
    getActionContext(context);

  if (action?.calledFrom === 'form') {
    const resultado = await action.handler();
    // guarda el resultado en la sesion para que sobreviva al redirect
    await context.session?.set('ultimoResultado', serializeActionResult(resultado));
    setActionResult(action.name, serializeActionResult(resultado));
  }
  return next();
});

El flujo completo se lee así: el formulario envía con POST; el middleware detecta que la action vino de un formulario —action.calledFrom === 'form'—, ejecuta su handler, serializa el resultado con serializeActionResult y lo deja en la sesión; luego la página redirige con un GET, y en esa nueva petición Astro.getActionResult recupera el estado persistido para renderizarlo. El serializeActionResult es imprescindible porque el resultado puede contener un Date, un Map o un ActionError, cosas que un JSON pelado no sabe guardar; su inverso, deserializeActionResult, lo devuelve a la vida al leerlo.

flowchart TD
SUB[form POST hacia actions crearTarea] --> MW[middleware getActionContext]
MW --> CF{calledFrom es form}
CF -->|si| HND[ejecuta el handler]
HND --> SER[serializeActionResult]
SER --> SES[guarda en la session]
SES --> RED[redirect GET a la misma pagina]
RED --> GAR[Astro getActionResult recupera el estado]
GAR --> HTML[render con data o con errores]
style HTML fill:#a6e3a1,color:#11111b
style RED fill:#89b4fa,color:#11111b

Con este circuito, un formulario sin JavaScript alcanza la misma robustez que uno con toda la maquinaria del cliente: valida, muestra errores por campo, confirma el éxito y sobrevive a un refresco sin reenviarse. Es la mejora progresiva en su forma más plena: la base funciona para todos, y el JavaScript, si aparece, solo recorta las recargas.

⚠️
El estado persistido necesita un almacén

Persistir el resultado a través de un redirect exige un lugar donde guardarlo entre dos peticiones: la sesión de Astro, respaldada por su almacén. Sin ese almacén configurado, setActionResult no tiene dónde dejar el estado y tras el redirect no habrá nada que recuperar. Antes de montar el patrón post-redirect-get, asegúrate de tener sesiones disponibles; de lo contrario, el resultado vivirá solo durante la petición del POST y se perderá en cuanto redirijas.

Rastro en la URL y cómo limpiarlo

Cuando un formulario se envía a una action sin JavaScript, Astro añade a la URL un par de parámetros internos para saber qué action se llamó y con qué datos. El objeto ACTION_QUERY_PARAMS expone los nombres de esos parámetros, útil si quieres borrarlos al redirigir para que el usuario acabe en una dirección limpia, sin el rastro de la mecánica interna.

import { ACTION_QUERY_PARAMS } from 'astro:actions';

const url = new URL(context.request.url);
url.searchParams.delete(ACTION_QUERY_PARAMS.actionName);
url.searchParams.delete(ACTION_QUERY_PARAMS.actionPayload);

Este barrido es el toque final del patrón post-redirect-get: no solo evitas el reenvío al refrescar, sino que dejas la barra de direcciones tan aseada como si el envío nunca hubiera dejado huella. El estado vive en la sesión, no en la URL, que es justo donde debe vivir.

🔗

action

Enlaza un <form> a una action. El navegador la envia sin necesidad de scripts.

📥

getActionResult

En el frontmatter, devuelve el data o el error del envio de esta peticion.

🧭

getActionContext

En el middleware, captura el resultado de un formulario para persistirlo.

🔄

post-redirect-get

Redirige tras el POST para que un refresco no reenvie el formulario.

La mejora progresiva es respetar el grano de la web

Enlazar un formulario a una action y verlo funcionar sin JavaScript no es una curiosidad retro: es una postura sobre cómo debe construirse la web. El formulario HTML es una de las abstracciones más antiguas y resistentes del navegador; funciona sin scripts, sin frameworks, sin que nada se hidrate, porque el envío de un <form> está tejido en el propio protocolo. Cuando una tecnología moderna elige apoyarse en esa base en vez de reemplazarla, está honrando lo que a veces se llama el grano de la web: la dirección en la que la plataforma quiere ser usada. La mejora progresiva invierte el orden habitual de la construcción. En lugar de escribir primero una aplicación que exige JavaScript y luego, quizá, degradarla para quien no lo tiene, partes de una base que funciona para todos —el formulario que envía y el servidor que responde— y añades el JavaScript como una capa que quita asperezas: evitar la recarga, validar al vuelo, actualizar sin viajar. Si esa capa no carga —una red pobre, un móvil viejo, un script que falló, un usuario que lo desactivó—, no se cae la funcionalidad, solo su pulido. Las actions encarnan esta filosofía en el punto donde otras arquitecturas la abandonan: el diálogo con el servidor. Muchas soluciones modernas hacen que hablar con el backend dependa por completo del cliente, de modo que sin JavaScript no hay envío posible; las actions, en cambio, funcionan igual por las dos vías, la nativa del navegador y la enriquecida del script, con una sola definición. La lección que trasciende Astro es que la robustez no se añade al final: se diseña desde la base, eligiendo cimientos que ya funcionan solos y tratando cada tecnología encima como una mejora prescindible y no como un requisito. Un formulario que funciona sin JavaScript no es un formulario pobre; es un formulario que decidió no apostar su existencia a que todo lo demás salga bien.

⚔️ Construye un formulario que resista sin scripts
  1. Enlaza un <form method="POST"> a una action con accept: 'form' mediante action={actions.suscribir} y compruébalo con el JavaScript del navegador desactivado.
  2. En el frontmatter, usa Astro.getActionResult e isInputError para pintar los errores de validación junto a cada campo, sin <script>.
  3. Escribe un middleware con getActionContext que capture el resultado de un envío de formulario y lo guarde en la sesión.
  4. Cierra el patrón post-redirect-get redirigiendo tras el POST y recuperando el estado con Astro.getActionResult; verifica que un refresco ya no reenvía el formulario.