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.
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.tsimport { 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.tsdeclare 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.
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.