Skip to content
C Codeloom
Astro

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.

·4 min read · By Codeloom
Intermediate 8 min read

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.locals to pass data to the page.
  • Call next() to continue to the page or endpoint, receiving a Response.
  • Return a Response directly 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
How middleware sits between the request and the handler

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().