Middleware is code that runs before Astro processes any page or endpoint. It allows us to intercept the request, modify it, or block it completely.
If you’ve worked with backend frameworks like Express or Koa, this concept will be familiar.
Unlike other frameworks where middleware only exists if you have a Node.js server running, in Astro middleware is available in both modes, although its execution timing changes:
In SSR mode (output: ‘server’): Middleware runs at request time. Every time a user visits a page, the middleware activates. It’s ideal for authentication, reading cookies, or dynamic redirects.
In SSG mode (output: ‘static’): Middleware runs at build time. It activates when you run
npm run buildwhile Astro generates the HTML pages. It’s useful for manipulating the final HTML, injecting data at build time, or generating build logs.
In this article, we’ll mainly focus on dynamic use cases (SSR), which is where middleware makes the most sense (route protection, users), but keep in mind the architecture is the same for both.
Creating the Middleware
In Astro, middleware is defined in a single special file: src/middleware.ts (or .js). You cannot give it another name or move it to another folder.
The basic structure is as follows:
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
// The 'onRequest' function intercepts ALL requests
export const onRequest = defineMiddleware(async (context, next) => {
console.log("Intercepted request to:", context.url.pathname);
// 'next()' passes control to the next step (render the page)
// We must return its response
const response = await next();
console.log("Page rendered. Returning response...");
return response;
});
This code acts like a “sandwich”. We do things before next() (pre-processing) and things after next() (post-processing).
Use Case 1: Route Protection
The most common use is to protect a section of the website. Let’s implement simple logic: if you try to access /dashboard and don’t have the session cookie, we send you to the login page.
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async (context, next) => {
// 1. Define which routes are private
const isPrivate = context.url.pathname.startsWith('/dashboard');
// 2. Check if the session cookie exists (only in SSR mode)
const hasSession = context.cookies.has('user_session');
// 3. Blocking logic
if (isPrivate && !hasSession) {
// If it's private and there's no session, redirect
return context.redirect('/login');
}
// 4. If everything is okay, let it pass
return next();
});
Now, any file you create inside src/pages/dashboard/ will be automatically protected. You don’t need to touch a single line of code in those pages.
Use Case 2: Data Injection
Middleware isn’t just for blocking. It’s also for preparing data.
Imagine you’ve already verified the cookie and know who the user is. It would be inefficient for the page to read the cookie again and look up the user in the database. The middleware has already done that.
To pass data from Middleware to Pages, we use the context.locals object.
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { getUserFromDB } from "./lib/db";
export const onRequest = defineMiddleware(async (context, next) => {
const token = context.cookies.get('token')?.value;
if (token) {
// Look up the user and save it in 'locals'
const user = await getUserFromDB(token);
if (user) {
context.locals.user = user;
}
}
return next();
});
Now, in any .astro component, we can access that user directly:
---
// src/pages/perfil.astro
const { user } = Astro.locals;
// If the middleware didn't find a user, redirect
if (!user) return Astro.redirect('/login');
---
<h1>Welcome, {user.name}</h1>
Chaining Middleware (sequence)
In large applications, having all the code in a single onRequest function can be chaotic. You might have authentication logic, translation logic (i18n), analytics logic, etc.
Astro allows us to split the middleware into small functions and chain them using sequence.
// src/middleware.ts
import { sequence } from "astro:middleware";
async function auth(context, next) {
console.log("1. Checking Auth");
// Auth logic...
return next();
}
async function validation(context, next) {
console.log("2. Validating data");
// Validation logic...
return next();
}
async function logging(context, next) {
console.log("3. Logging visit");
return next();
}
// Export the sequence in order
export const onRequest = sequence(auth, validation, logging);
Order matters: auth will run first. If auth decides to redirect and doesn’t call next(), validation and logging will never run.
Response Manipulation
Sometimes we want to modify the HTML response after the page has been rendered. For example, to add security headers or minify the HTML.
export const onRequest = defineMiddleware(async (context, next) => {
const response = await next(); // Wait for the HTML to be generated
// Modify headers
response.headers.set("X-Powered-By", "Astro + LuisLlamas");
// We can even modify the HTML (be careful with performance)
const html = await response.text();
const newHtml = html.replace("</body>", "</body>");
return new Response(newHtml, {
status: 200,
headers: response.headers
});
});
Intercepting and rewriting the HTML body (response.text()) has a high performance cost. Use it only if strictly necessary.
