Astro Actions y Middleware: Backend Moderno
Las Actions permiten invocar funciones del servidor con validación y tipos compartidos. El middleware prepara el contexto o controla el acceso antes de resolver una ruta. La validación de datos y la autorización son responsabilidades distintas.
1. Preparar un servidor #
Para ejecutar Actions necesitas un adaptador. Para practicar con Node:
1npx astro add node
📄 astro.config.mjs
1import { defineConfig } from 'astro/config'; 2import node from '@astrojs/node'; 3 4export default defineConfig({ 5 adapter: node({ mode: 'standalone' }), 6 security: { allowedDomains: [{ hostname: 'localhost' }, { hostname: '127.0.0.1' }] }, 7});
Al publicar, añade tu hostname real a security.allowedDomains; los hosts del ejemplo solo permiten probar localmente. Conserva tus integraciones de MDX, React y el plugin de Tailwind si ya los tienes. Las páginas siguen siendo estáticas por defecto, pero el adaptador permite atender Actions y rutas marcadas con prerender = false.
2. Definir una Action para JSON y otra para formularios #
📄 src/actions/index.ts
1import { defineAction, ActionError } from 'astro:actions'; 2import { z } from 'astro/zod'; 3 4const subscription = z.object({ 5 email: z.email({ error: 'Introduce un correo electrónico válido' }), 6}); 7 8function subscribe({ email }: z.infer<typeof subscription>) { 9 if (email.endsWith('@spam.com')) { 10 throw new ActionError({ 11 code: 'BAD_REQUEST', 12 message: 'Dominio de correo no permitido', 13 }); 14 } 15 // Demostración: aquí se conectaría el servicio real de suscripciones. 16 return { message: `Solicitud validada para ${email}` }; 17} 18 19export const server = { 20 newsletter: defineAction({ 21 input: subscription, // JSON por defecto 22 handler: subscribe, 23 }), 24 newsletterForm: defineAction({ 25 accept: 'form', 26 input: subscription, 27 handler: subscribe, 28 }), 29};
Este ejemplo valida una solicitud; no guarda suscriptores ni envía correos. En Zod 4 usamos z.email() e importamos z desde astro/zod.
3. Invocar desde JavaScript #
📄 src/pages/es/newsletter.astro
1<!doctype html> 2<html lang="es"> 3<head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 <title>Suscripción con JavaScript</title> 7</head> 8<body> 9<form id="newsletter-form"> 10 <label for="email">Correo electrónico</label> 11 <input type="email" id="email" name="email" required /> 12 <button type="submit">Suscribirme</button> 13 <p id="mensaje" role="status" aria-live="polite"></p> 14</form> 15 16<script> 17 import { actions } from 'astro:actions'; 18 19 function setupNewsletter() { 20 const form = document.querySelector<HTMLFormElement>('#newsletter-form'); 21 const mensaje = document.querySelector<HTMLParagraphElement>('#mensaje'); 22 const button = form?.querySelector('button'); 23 if (!form || !mensaje || !button || form.dataset.ready) return; 24 form.dataset.ready = 'true'; 25 26 form.addEventListener('submit', async event => { 27 event.preventDefault(); 28 button.disabled = true; 29 mensaje.textContent = 'Enviando…'; 30 try { 31 const email = String(new FormData(form).get('email') ?? ''); 32 const { data, error } = await actions.newsletter({ email }); 33 mensaje.textContent = error ? error.message : data.message; 34 if (!error) form.reset(); 35 } catch { 36 mensaje.textContent = 'No se pudo conectar. Inténtalo de nuevo.'; 37 } finally { 38 button.disabled = false; 39 } 40 }); 41 } 42 setupNewsletter(); 43 document.addEventListener('astro:page-load', setupNewsletter); 44</script> 45</body> 46</html>
La función se ejecuta al cargar; con ClientRouter, astro:page-load la ejecuta también en las siguientes visitas. data-ready impide registrar dos veces el listener.
Los archivos del ejemplo están bajo /es/ para poder combinarlos con el portfolio bilingüe. También funcionan en un proyecto sin configuración i18n.
4. Formulario HTML sin JavaScript #
📄 src/pages/es/suscripcion.astro
1--- 2export const prerender = false; 3import { actions } from 'astro:actions'; 4const result = Astro.getActionResult(actions.newsletterForm); 5--- 6<!doctype html> 7<html lang="es"> 8<head> 9 <meta charset="UTF-8" /> 10 <meta name="viewport" content="width=device-width, initial-scale=1" /> 11 <title>Suscripción sin JavaScript</title> 12</head> 13<body> 14<form method="POST" action={actions.newsletterForm}> 15 <label for="correo">Correo electrónico</label> 16 <input id="correo" type="email" name="email" required /> 17 <button type="submit">Enviar</button> 18</form> 19<p role="status"> 20 {result?.error ? result.error.message : result?.data?.message} 21</p> 22</body> 23</html>
La Action debe aceptar formulario, y la página que lee su resultado debe renderizarse bajo demanda. Una página prerenderizada no puede procesar ese resultado por visitante. Prueba un correo válido y otro de spam.com, también con JavaScript desactivado. Formularios y Actions.
5. Sesiones y contexto del middleware #
Una sesión conserva datos en el servidor entre peticiones; Astro.locals comparte datos únicamente durante la petición actual. Node, Netlify y Cloudflare configuran un driver de sesiones por defecto; para otros destinos, como Vercel, debes configurar uno compatible con tu alojamiento. El almacenamiento debe ser persistente y compartido si hay varias instancias.
Declara los tipos en 📄 src/env.d.ts:
1declare namespace App { 2 interface SessionData { 3 user: { id: string; nombre: string; rol: 'alumno' | 'admin' }; 4 } 5 interface Locals { 6 user?: SessionData['user']; 7 } 8}
📄 src/middleware.ts
1import { defineMiddleware } from 'astro:middleware'; 2 3export const onRequest = defineMiddleware(async (context, next) => { 4 if (context.url.pathname === '/admin' || context.url.pathname.startsWith('/admin/')) { 5 const user = await context.session?.get('user'); 6 if (!user) return context.redirect('/login'); 7 if (user.rol !== 'admin') return new Response('Acceso denegado', { status: 403 }); 8 context.locals.user = user; 9 } 10 const response = await next(); 11 response.headers.set('X-Frame-Options', 'DENY'); 12 return response; 13});
La sesión solo debe recibir user después de verificar credenciales con tu sistema de autenticación, tomando ID y rol del registro validado en servidor. El ejemplo protege una sesión existente; no implementa un proveedor de login. La mera existencia de una cookie o un rol recibido del formulario no autentica a nadie. Para cerrar sesión utiliza await Astro.session?.destroy() en una ruta o Action de servidor.
6. Página privada bajo demanda #
📄 src/pages/admin/dashboard.astro
1--- 2export const prerender = false; 3const user = Astro.locals.user; 4if (!user) return Astro.redirect('/login'); 5--- 6<h1>Panel de control</h1> 7<p>Bienvenido, {user.nombre}</p>
Una ruta privada no debe prerenderizarse: el middleware de una ruta estática se ejecuta al compilar, no en cada visita. Prueba que una petición sin sesión redirige a login y que una sesión sin el rol necesario recibe 403. Sesiones.
7. Proteger también las Actions #
Las Actions son endpoints públicos bajo /_actions/. Proteger /admin no protege automáticamente sus mutaciones. En el handler de cualquier Action restringida comprueba la sesión, el permiso sobre el recurso y después ejecuta el cambio. La validación Zod por sí sola no realiza esas comprobaciones. Seguridad de Actions.
Resumen del tema
- Las Actions necesitan adaptador; las que reciben formularios usan
accept: 'form'. - La página de un formulario nativo desactiva el prerenderizado.
- Zod valida entradas; el handler comprueba permisos.
- La sesión persiste datos entre peticiones;
localscomunica datos dentro de una petición. - Las páginas privadas se renderizan bajo demanda y verifican una sesión autenticada.