Astro Middleware Tutorial
Use Astro middleware to run code on every request: auth gates, locals injection, redirects, and response headers. Learn the onRequest signature, the next() flow, sequencing, and common production patterns.
What you'll learn
- ✓What middleware is in Astro and where it runs
- ✓The onRequest signature and the next() function
- ✓How to populate Astro.locals from middleware
- ✓How to sequence multiple middlewares cleanly
- ✓Pitfalls around static pages and response mutation
Prerequisites
- •An Astro project with server or hybrid output
What and Why
Middleware in Astro is a single hook that runs on every request, before the page or endpoint handler. You use it to centralize cross-cutting concerns: authentication, request logging, locale detection, feature flags, security headers. Anything you would otherwise paste into every route belongs in middleware.
The why is consistency. A request either gets through the gate the same way every time, or you have a security bug. A redirect rule lives in one file or scattered across forty. Middleware is the seam where you decide.
Mental Model
You define a file src/middleware.ts that exports onRequest(context, next). On every request that hits the Astro server, this function runs first. It can:
- Read or mutate
context.localsto pass data to the page. - Call
next()to continue to the page or endpoint, receiving aResponse. - Return a
Responsedirectly to short-circuit the pipeline. - Modify the returned
Response(add headers, rewrite cookies) before sending.
next() is the rest of the request pipeline as a function. Call it once. If you do not call it, the request never reaches its handler.
Hands-on Example
A common setup: detect a session cookie, attach the user to locals, redirect unauthenticated requests to /login for protected paths, and add security headers to every response.
// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';
import { getUserFromSession } from '@/lib/auth';
const auth = defineMiddleware(async (context, next) => {
const sid = context.cookies.get('sid')?.value;
context.locals.user = sid ? await getUserFromSession(sid) : null;
const path = context.url.pathname;
if (path.startsWith('/app') && !context.locals.user) {
return context.redirect('/login');
}
return next();
});
const headers = defineMiddleware(async (_, next) => {
const res = await next();
res.headers.set('x-frame-options', 'DENY');
res.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
return res;
});
export const onRequest = sequence(auth, headers);
Request
|
v
[ auth middleware ] -- reads cookie, sets locals.user
| may redirect early
v
[ headers middleware ] -- calls next() first
| then mutates response
v
[ page or endpoint ] -- reads Astro.locals.user
|
v
Response back through middlewares -> client
Inside a page, you read what middleware set:
---
const { user } = Astro.locals;
---
<p>Hello, {user?.name ?? 'guest'}</p>
Make sure to declare the shape of locals in env.d.ts:
declare namespace App {
interface Locals { user: { id: string; name: string } | null }
}
Common Pitfalls
The biggest trap is forgetting to call next(). The request hangs or times out, and the error surface depends on the adapter. Always either return next() or return a Response.
The second trap is mutating the response before awaiting next(). You cannot set headers on a response that does not exist yet. Always do const res = await next(); first.
The third is assuming middleware runs on static pages. In static output, prerendered pages skip middleware entirely. Use hybrid or server mode for routes that need it, or mark specific pages prerender = false.
The fourth is putting heavy work in middleware. It runs on every request, including assets in some adapters. Keep it fast; cache expensive lookups.
Finally, watch the order in sequence(). Auth before headers means redirects skip the header pass. If you need headers on every response, put them last and rely on early returns to still go through them.
Best Practices
Split middleware by concern and combine with sequence(). One file for auth, one for headers, one for logging is far easier to maintain than a single 200-line onRequest.
Type Astro.locals properly in env.d.ts. Without it, page authors guess what middleware sets, and bugs follow.
Short-circuit early for forbidden paths. The earlier you return, the less work the server does on bad requests.
Treat the returned Response as immutable in spirit. Clone or wrap if you need major changes, and only set headers if it is truly a cross-cutting concern.
Wrap-up
Astro middleware is one file and one function, but it is the spine of any non-trivial Astro app. Use it to enforce auth, attach per-request data, and set headers in one place. Mind static prerendering, always call next(), and split concerns with sequence().
Related articles
- Astro Astro Islands Architecture Explained
Learn how Astro ships zero JavaScript by default and only hydrates the interactive components you mark as islands.
- Astro Astro Server Endpoints Tutorial
Build JSON APIs and dynamic responses directly in Astro using server endpoints. Learn the file conventions, request and response shapes, dynamic params, and how to mix endpoints with static and SSR pages.
- Astro Astro vs Next.js Comparison
Compare Astro and Next.js across rendering models, performance defaults, ecosystem, and use cases to pick the right framework for your project.
- Astro Astro Content Collections Tutorial
Build a typed blog with Astro content collections, including Zod schemas, references, and dynamic routes generated from Markdown files.