context.locals: del middleware a las páginas, tipado
El canal por el que el middleware entrega datos a las páginas que atiende una petición. Qué es context.locals, por qué es un objeto nuevo y aislado por cada petición, cómo declarar su forma una sola vez con el espacio de nombres App.Locals en env.d.ts, y cómo leerlo tipado desde una página con Astro.locals y desde un endpoint con context.locals.
El middleware corre antes que la página y ve la petición primero: valida la sesión, resuelve el idioma, calcula lo que haga falta. Pero ese trabajo no sirve de nada si no puede entregárselo a la página que renderiza a continuación. Ese es el oficio de context.locals: un objeto que Astro crea nuevo para cada petición y que viaja con ella, un maletín donde el middleware deposita datos a la ida y del que la página los saca a la vuelta. Y como no queremos un saco anónimo, sino un contrato claro, Astro nos deja declarar su forma una única vez —App.Locals— para que el canal quede tipado en las dos puntas.
- Entender
context.localscomo un objeto nuevo y aislado por petición. - Escribir datos en
localsdesde el middleware a la ida. - Declarar la forma de
localscon el espacio de nombresApp.Locals. - Leer
localstipado desde páginas conAstro.localsy desde endpoints.
locals: el maletín que viaja con la petición
context.locals es un objeto vacío que Astro crea nuevo para cada petición y que la acompaña de principio a fin. El middleware puede escribir en él lo que quiera —un usuario, un idioma, un identificador de traza— y todo lo que deposite estará disponible, sin más gestiones, cuando la página o el endpoint de esa petición se renderice. Es un canal de un solo sentido en el tiempo: el middleware corre antes y llena el maletín; la página corre después y lo abre.
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const sid = context.cookies.get('sid')?.value;
context.locals.usuario = await buscarSesion(sid);
return next();
});
Lo decisivo es que ese maletín es por petición. Astro no comparte locals entre visitas distintas: cada una recibe el suyo, recién creado, y lo descarta al terminar. Esa propiedad, que parece un detalle, es justo lo que vuelve seguro el patrón en un servidor que atiende muchas peticiones a la vez. Si guardaras el usuario en una variable global del módulo, dos visitantes simultáneos se pisarían los datos; con locals, cada petición tiene su compartimento aislado y no hay fuga posible entre unas y otras.
Tipar locals con App.Locals
Por defecto, locals es un objeto sin forma declarada: TypeScript no sabe qué guardas en él, así que leer Astro.locals.usuario desde una página no te da autocompletado ni comprobación. La solución es describir la forma una sola vez, en el espacio de nombres global App, dentro de tu archivo src/env.d.ts. Ahí defines la interfaz Locals con los campos que tu middleware promete rellenar.
// src/env.d.ts
declare namespace App {
interface Locals {
usuario: { id: string; nombre: string } | null;
idioma: 'es' | 'en';
}
}
Con esa declaración en su sitio, locals deja de ser un saco anónimo y pasa a ser un contrato tipado en ambos extremos del canal. En el middleware, si intentas escribir context.locals.usuario = 42, TypeScript protesta porque un número no encaja con la forma prometida. En la página, al leer Astro.locals.usuario, el editor te ofrece id y nombre y te obliga a contemplar el caso null. Una sola fuente de verdad sobre la forma de los datos que cruzan de un lado al otro, honrada por todos los que tocan el maletín.
El espacio de nombres App es el punto de extensión que Astro reserva para que tú describas cosas propias del proyecto, y Locals es su interfaz más usada. Se declara en src/env.d.ts, el mismo archivo que ya contiene la referencia a los tipos generados de Astro. No tienes que importar nada ni exportar nada: al ser una declaración de espacio de nombres global, Astro la recoge automáticamente y la aplica a context.locals y a Astro.locals en todo el proyecto. Amplías la interfaz cada vez que tu middleware empieza a depositar un dato nuevo.
Leer locals en páginas y endpoints
En una página .astro, locals se lee a través del objeto global Astro, como cualquier otra propiedad del contexto de la petición:
---
const { usuario } = Astro.locals;
---
{usuario ? <p>Hola, {usuario.nombre}</p> : <a href="/entrar">Iniciar sesion</a>}
En un endpoint —una ruta .ts que exporta un método HTTP— locals llega dentro del mismo objeto de contexto que recibe el manejador, así que lo desestructuras igual:
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ locals }) => {
if (!locals.usuario) {
return new Response(null, { status: 401 });
}
return Response.json({ nombre: locals.usuario.nombre });
};
En ambos casos el dato ya está ahí, resuelto por el middleware una sola vez, sin que la página ni el endpoint sepan de dónde salió ni tengan que volver a buscarlo. Ese es el valor real de locals: mueve el trabajo caro —validar una sesión, consultar una base de datos, resolver un idioma— al punto único por el que todo pasa, y reparte el resultado ya cocinado a quien lo necesite. La página se vuelve declarativa: pinta según el usuario, sin ocuparse de averiguar quién es.
locals vive en el servidor y muere con la petición
Conviene marcar una frontera que a veces se cruza por error: locals es una construcción del servidor, no un canal hacia el navegador. Lo que depositas en él existe durante el renderizado de la petición y desaparece cuando esta termina; no se serializa, no se manda al cliente, no sobrevive a la respuesta. Es memoria de trabajo del lado del servidor, no un almacén que el navegador pueda leer después.
De esa naturaleza se siguen dos consecuencias. La primera: puedes guardar en locals cosas que no quieres exponer —un token interno, el resultado de una consulta con datos delicados— con la tranquilidad de que no cruzarán la red por sí solas, porque al cliente solo llega lo que una página decide pintar. La segunda: locals no sirve para persistir nada entre visitas. Si un dato debe sobrevivir a la petición —una preferencia, una sesión—, su hogar es una cookie o un almacén externo, y el middleware es quien lee esa fuente duradera y vuelca su resultado en el maletín efímero de cada petición.
export const onRequest = defineMiddleware(async (context, next) => {
const token = context.cookies.get('sid')?.value; // fuente duradera
context.locals.usuario = token ? await verificar(token) : null; // maletin efimero
return next();
});
Esa división del trabajo —fuente persistente fuera, maletín efímero dentro— es el patrón sano. La cookie o la base de datos guardan el hecho a lo largo del tiempo; locals lo transporta, ya resuelto, a lo ancho de una sola petición. Confundir los papeles —querer que locals recuerde, o que la cookie cargue objetos ricos— es la raíz de los enredos más comunes con el estado en el servidor.
flowchart LR RQ[peticion entra] --> MW[middleware llena locals] MW --> NX[next renderiza la ruta] NX --> PG[la pagina lee Astro locals] NX --> EP[el endpoint lee context locals] PG --> H[html segun el usuario] EP --> J[json segun el usuario] style RQ fill:#89b4fa,color:#11111b style MW fill:#f9e2af,color:#11111b style H fill:#a6e3a1,color:#11111b style J fill:#a6e3a1,color:#11111b
Un tropiezo frecuente es olvidar el await en el middleware y guardar en locals la promesa en vez del dato resuelto. Si escribes context.locals.usuario = buscarSesion(sid) sin esperarla, la página recibe un objeto pendiente en lugar del usuario, y la comprobación if (usuario) da verdadero aunque no haya nadie. Resuelve el valor en el middleware —espéralo antes de asignarlo— para que la página reciba algo concreto y no una promesa disfrazada. El maletín es para datos ya cocinados, no para encargos a medio hacer.
Cada vez que un middleware empieza a depositar un dato nuevo en locals, añade su campo a la interfaz App.Locals. Mantener la declaración al día tiene premio inmediato: el editor te avisa si una página lee un campo que ningún middleware rellena, o si un middleware escribe algo que no habías declarado. La interfaz se vuelve así un contrato vivo entre quien llena el maletín y quien lo abre, con el compilador de árbitro entre los dos.
Un maletin por peticion
locals nace vacio en cada visita, viaja con ella y se descarta al terminar. Nunca se reusa entre peticiones.
Aislado y seguro
Nada se comparte entre peticiones concurrentes, asi que dos visitantes a la vez no se pisan los datos.
App.Locals
Declara la forma una vez en env.d.ts y el canal queda tipado en el middleware y en la pagina.
Se lee igual en todo
Astro.locals en paginas, el mismo objeto en endpoints. El dato ya esta resuelto, nadie lo busca dos veces.
Lo que context.locals implementa, sin nombrarlo, es una de las ideas más productivas de la ingeniería de software: la inyección de dependencias con un alcance bien elegido. Piensa en el problema que resuelve. Una página necesita saber quién es el usuario, pero averiguarlo —leer una cookie, verificar una firma, consultar un almacén— es un trabajo que no tiene nada que ver con pintar la página, y que además querrías hacer una sola vez y no repetir en cada archivo. La respuesta clásica a ese dilema es invertir la dependencia: en vez de que cada página vaya a buscar lo que necesita, alguien se lo entrega ya resuelto, y la página se limita a recibirlo y usarlo. locals es exactamente ese alguien, y el middleware es quien inyecta. Pero la sutileza que hace que todo funcione en un servidor —y la que distingue a locals de una simple variable global compartida— es su alcance: no es global ni es local a una función, es local a la petición. Esa es la unidad correcta, y no es casualidad. Una petición web es el ámbito natural de todo lo que es cierto ahora, para este visitante, en esta visita: quién es, qué idioma habla, qué permiso tiene. Ligar los datos a esa unidad —crearlos cuando la petición nace, tirarlos cuando muere, no compartirlos jamás con otra— es lo que hace que la concurrencia deje de dar miedo, porque cada petición vive en su propia burbuja y ninguna puede envenenar a otra. Cuando entiendes locals así, dejas de verlo como un objeto donde meter cosas y empiezas a verlo como la materialización de un principio: que el contexto de un cómputo debe viajar con el cómputo, ni escondido en un global que todos comparten ni recalculado por cada quien lo necesita. El middleware resuelve una vez, en el borde; locals transporta el resultado hacia dentro; y cada página, liberada de averiguar nada, se queda solo con lo suyo: decidir qué mostrar.
- En el middleware, lee una cookie de sesión y guarda el usuario resuelto (con
await) encontext.locals.usuario. - Declara
App.Localsensrc/env.d.tscon la forma del usuario y comprueba que el editor te autocompletanombreen una página. - Lee
Astro.locals.usuarioen una página y pinta un saludo si existe o un enlace de login si esnull. - Crea un endpoint que responda
401cuandolocals.usuariosea nulo y un JSON con el nombre cuando exista, sin volver a leer la cookie.