Qué son las Actions: funciones de servidor type-safe
El fin del ritual de escribir endpoints a mano para hablar con el servidor: una action es una función type-safe declarada en src/actions/index.ts con defineAction, invocable desde el cliente como si fuera local, con los tipos intactos de un extremo al otro y un resultado data/error que nunca deja cruzar formas rotas por el cable.
Durante años, mover un dato del navegador al servidor —guardar un comentario, marcar un favorito, procesar un envío— exigió el mismo ritual: escribir un endpoint, inventar una URL, serializar la petición a mano, parsear la respuesta y confiar en que las formas coincidieran a ambos lados de un cable que TypeScript no puede cruzar. Las Actions de Astro borran ese ritual. Una action es una función de servidor que declaras una vez y llamas desde el cliente como si fuera local, con los tipos intactos de un extremo al otro. No hay URL que recordar ni JSON que parsear: hay una función que, por dentro, viaja por la red, pero que por fuera se comporta como cualquier otra.
- Reconocer la action como una función type-safe de servidor invocable desde el cliente.
- Declarar el objeto
servery una primera action ensrc/actions/index.tscondefineAction. - Entender el resultado
{ data, error }y por qué una action nunca deja cruzar tipos rotos por el cable. - Situar las actions frente a los endpoints y ante el requisito de renderizado en el servidor.
El problema: hablar con el servidor sin contrato
Antes de las actions, comunicar el cliente con el servidor pasaba siempre por un endpoint: un fichero en src/pages/api que exporta una función por método HTTP, recibe un Request crudo y devuelve un Response. Funciona, pero deja toda la carga sobre tus hombros. Inventas una URL —/api/crear-tarea— y la escribes como una cadena en el fetch del cliente; serializas el cuerpo con JSON.stringify; en el servidor lo vuelves a parsear; y decides a mano un código de estado y una forma de respuesta que el cliente tendrá que interpretar.
El problema de fondo no es la verbosidad, sino que el cable rompe los tipos. TypeScript comprueba tu código dentro de un módulo, pero no puede seguir una petición HTTP: para el compilador, lo que sale del fetch es un any o, en el mejor caso, un tipo que tú afirmaste sin que nadie lo verifique. El servidor cree que recibe un número; el cliente envía una cadena; ambos compilan; el fallo aparece en producción. Cada endpoint es dos contratos escritos por separado —el que envía y el que recibe— que la nada garantiza que sigan de acuerdo.
// El ritual antiguo: una URL como cadena y un JSON sin tipos
const res = await fetch('/api/crear-tarea', {
method: 'POST',
body: JSON.stringify({ titulo: 'Comprar pan' }),
});
const data = await res.json(); // any: el cable borro el tipo
Las actions atacan justo esa fractura. En lugar de dos contratos que hay que mantener sincronizados a mano, hay uno solo, escrito en el servidor, del que Astro deriva automáticamente el lado del cliente. La URL desaparece de tu vista, la serialización se automatiza y —lo esencial— el tipo de lo que devuelve el servidor es el tipo que recibe el cliente, porque es literalmente el mismo, inferido de una única fuente.
defineAction y el objeto server
Todas las actions viven en un único punto de entrada: src/actions/index.ts, que exporta una constante llamada server. Es un objeto donde cada clave es el nombre de una action y cada valor, el resultado de defineAction. Esa función recibe un handler —la lógica de servidor que se ejecutará— y, opcionalmente, un input que valida los argumentos antes de entrar.
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
saludar: defineAction({
input: z.object({ nombre: z.string() }),
handler: async (input) => {
return `Hola, ${input.nombre}`;
},
}),
};
Tres hechos definen a esta pieza. Primero, el handler corre solo en el servidor: su cuerpo nunca se empaqueta para el navegador, así que dentro puedes leer una base de datos, usar una clave secreta o tocar el sistema de ficheros sin miedo a filtrarlo. Segundo, lo que el handler retorna se convierte, del lado del cliente, en el data de la llamada, con su tipo inferido: aquí, una string. Y tercero, al arrancar el servidor de desarrollo Astro ejecuta astro sync, que genera el objeto actions del cliente y sus tipos a partir de este fichero; por eso el editor conoce saludar sin que importes nada manualmente.
El objeto server admite anidamiento, y eso te deja agrupar actions por dominio. Cuando una clave apunta a otro objeto de actions en vez de a una sola, el cliente refleja esa jerarquía: bajo actions.tareas conviven crear y borrar, ordenadas como carpetas de un mismo archivador. En un proyecto que crece, esa organización evita que un único objeto plano degenere en un cajón de sastre con decenas de nombres sueltos.
export const server = {
tareas: {
crear: defineAction({ /* ... */ }),
borrar: defineAction({ /* ... */ }),
},
};
// En el cliente: await actions.tareas.crear({ /* ... */ })
Todo lo que escribas dentro de un handler se queda en el servidor. No hay riesgo de que una clave de API o una consulta SQL termine en el bundle del navegador, porque ese código jamás cruza al cliente: lo único que viaja es el nombre de la action y sus datos. Esta frontera —cuerpo en el servidor, invocación en el cliente— es lo que hace de una action algo distinto de una simple función compartida.
El objeto actions del cliente y sus tipos los genera astro sync, que corre solo al arrancar el servidor de desarrollo. Cuando añades una action y el editor no la reconoce, casi siempre basta con reiniciar el servidor —o lanzar astro sync a mano— para que regenere los tipos y la nueva entrada aparezca autocompletada. Es el mismo mecanismo que mantiene al día los tipos de las content collections.
Una función, dos mundos
Desde el cliente, la action se llama a través del objeto actions, importado de astro:actions. La invocación parece una llamada local, pero por debajo Astro hace un fetch a un endpoint que él mismo generó —bajo la ruta interna _actions— ejecuta el handler en el servidor y te devuelve el resultado ya deserializado.
<script>
import { actions } from 'astro:actions';
const { data, error } = await actions.saludar({ nombre: 'Ada' });
if (!error) {
console.log(data); // "Hola, Ada", tipado como string
}
</script>
Observa la forma del retorno: { data, error }, no un valor pelado. Una action nunca lanza por un fallo esperado; en su lugar devuelve una unión discriminada donde, o bien data trae el resultado y error es undefined, o bien error describe qué salió mal y data es undefined. Esa forma es deliberada y la estudiaremos en su propia lección: obliga a mirar el error antes de tocar el dato. La serialización, además, es más rica que un JSON crudo —Astro usa la librería devalue—, de modo que un Date, un Map, un Set o una URL que retornes en el servidor llegan al cliente como lo que son, no como cadenas.
La serialización con devalue es más generosa que un JSON.stringify, pero no infinita. Viajan sin problema los primitivos, los objetos y los arrays, y también Date, Map, Set, URL y BigInt. No viajan las funciones, las clases con métodos ni un Response a medio construir: si un handler intenta devolver algo así, replantéalo para retornar datos planos y deja el comportamiento en el servidor. La regla mental es simple: retorna hechos, no conductas.
flowchart LR
CLI[cliente llama a actions saludar] --> EP[endpoint _actions generado]
EP --> H[handler en el servidor]
H --> V{input valido}
V -->|si| OK[data serializado con devalue]
V -->|no| ERR[error BAD_REQUEST]
OK --> CLI
ERR --> CLI
style OK fill:#a6e3a1,color:#11111b
style ERR fill:#f38ba8,color:#11111bLa misma action se puede invocar desde el servidor con Astro.callAction, útil cuando una página quiere reutilizar la lógica sin pasar por la red. Así, el handler se vuelve una única fuente de verdad que sirve a los dos mundos: el cliente lo alcanza por el cable, el servidor lo llama en el sitio, y ninguno duplica la lógica.
Actions frente a endpoints
Las actions no jubilan a los endpoints; los complementan. Un endpoint es una herramienta de bajo nivel: te entrega el Request y espera un Response, y con ese control puedes construir una API REST pública, atender un webhook de terceros o servir un fichero con las cabeceras exactas que quieras. Una action es de más alto nivel: renuncia a ese control a cambio de tipos, validación y serialización automáticas para el caso más común, que es tu propio cliente hablando con tu propio servidor.
defineAction
Declara una action con su handler y su input opcional. Vive en el objeto server.
server
El objeto de src/actions/index.ts que reune todas tus actions bajo un nombre cada una.
actions
El proxy tipado del cliente que Astro genera. Llamarlo dispara la peticion por ti.
data y error
La forma del retorno: exito con data, o fallo con error. Nunca lanza por sorpresa.
La frontera entre ambos no es rígida: nada te impide tener las dos cosas en el mismo proyecto, un puñado de endpoints para lo que el mundo exterior consume y un repertorio de actions para lo que tu propia interfaz necesita. Elegir bien es preguntarse quién llama: si es un tercero que espera una URL estable y un contrato HTTP explícito, un endpoint; si eres tú desde tu cliente y lo que quieres son tipos y menos ceremonia, una action.
Hay un requisito que conviene tener presente desde el principio: como el handler se ejecuta en cada petición, las actions necesitan renderizado bajo demanda, es decir, un adaptador de servidor configurado. Un sitio por lo demás estático puede usar actions, pero su ruta interna se sirve en el servidor. Elegir una action es, por tanto, aceptar que hay un servidor vivo detrás; a cambio recibes un puente cliente-servidor sin costuras.
La idea profunda de las Actions no es que ahorren teclas, sino que disuelven una frontera que creíamos infranqueable para los tipos. Toda aplicación web tiene una grieta en el centro: de un lado el navegador, del otro el servidor, y entre ambos un cable que solo transporta bytes. Durante décadas, cruzar esa grieta significó bajar al terreno sin tipos —construir una URL, serializar, parsear, adivinar la forma de la respuesta— y volver a subir del otro lado esperando que las dos orillas coincidieran. Las actions materializan una vieja aspiración de la informática distribuida, la llamada a procedimiento remoto: que invocar una función que vive en otra máquina se parezca, para quien la llama, a invocar una función local. Astro lo logra generando el lado del cliente a partir del servidor, de modo que hay una sola declaración y el compilador puede seguir el tipo de un extremo al otro sin que el cable lo borre. Lo que ganas no es comodidad, es certeza: el mismo tipo que retorna tu handler es el que recibe tu <script>, y si cambias uno, el otro deja de compilar. Pero cuidado con confundir la abstracción con la desaparición de lo abstraído: la grieta sigue ahí. Los datos siguen viajando por una red que cualquiera puede inspeccionar, el cliente sigue siendo un entorno hostil que puede mentir, y el handler sigue siendo la única muralla real. La action hace que hablar con el servidor sea trivial; no hace que confiar en el cliente sea seguro. Interiorizar las dos mitades de esa verdad —la ergonomía que colapsa la distancia y la frontera de seguridad que persiste debajo— es lo que separa a quien usa actions de quien las entiende.
- Crea
src/actions/index.ts, exportaservery define una actionsaludarcon uninputdenombrey unhandlerque devuelva un saludo. - Desde un
<script>de una página, llama aactions.saludary comprueba en el editor quedataestá tipado comostringsin que hayas anotado nada. - Provoca a propósito un fallo pasando un
nombrenumérico y observa que el resultado llega porerror, no como una excepción. - Explica en dos frases por qué una action necesita un adaptador de servidor mientras que un componente
.astroestático no.