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.
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.
- 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
getActionContexty 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.
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:#11111bCon 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.
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.
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.
- Enlaza un
<form method="POST">a una action conaccept: 'form'medianteaction={actions.suscribir}y compruébalo con el JavaScript del navegador desactivado. - En el frontmatter, usa
Astro.getActionResulteisInputErrorpara pintar los errores de validación junto a cada campo, sin<script>. - Escribe un middleware con
getActionContextque capture el resultado de un envío de formulario y lo guarde en la sesión. - Cierra el patrón post-redirect-get redirigiendo tras el
POSTy recuperando el estado conAstro.getActionResult; verifica que un refresco ya no reenvía el formulario.