Type something to search...
What is Astro middleware and how to use it?

What is Astro middleware and how to use it?

Astro middleware is a function that runs before a page or endpoint is rendered, letting you inspect the request, share data with the route, or return a different response. You create it by exporting an onRequest() function from src/middleware.ts (or src/middleware/index.ts). It receives a context object, with the request, URL, cookies and a locals object, and a next() function that renders the route and returns its Response. Call next() to continue, or return your own Response to stop early, for example a redirect to a login page. Data you put on context.locals is available in pages as Astro.locals, and several middleware functions can be chained in order with sequence().

Middleware is the natural place for anything that applies to many routes at once: authentication checks, loading the current user, adding security headers, logging, redirects and rewrites. In this article you'll learn the basic structure, how to type and use locals, how to chain middleware and in which order it runs, how to protect routes, change responses, rewrite requests, and the important difference between middleware on prerendered and on-demand pages.

Creating Your First Middleware

Create src/middleware.ts and export a named onRequest function. It must not be a default export:

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware((context, next) => {
  context.locals.requestId = crypto.randomUUID();
  return next();
});

defineMiddleware() doesn't change behaviour; it gives you types for context and next, and a TypeScript error if you return something that isn't a Response. In plain JavaScript you can write export function onRequest(context, next) and, if you like, annotate it with the MiddlewareHandler type from astro through JSDoc.

Then read the value in any page, layout or component:

---
// src/pages/index.astro
const { requestId } = Astro.locals;
---
<p>Request {requestId}</p>

The middleware function must return a Response, either the one from next() or one you create.

The context Object

context is the same API context that endpoints receive, and it mirrors most of the Astro global. The parts you'll use most:

  • context.request: the standard Request, including headers, method and body.
  • context.url: a URL object for the request.
  • context.cookies: read and set cookies, like Astro.cookies.
  • context.locals: an object for passing data to the route.
  • context.params: route parameters for dynamic routes.
  • context.redirect() and context.rewrite(): send the visitor elsewhere, or render a different route.
  • context.isPrerendered: true when the current route is being prerendered.

Sharing Data With locals

locals lives for a single request. Anything you store there, strings, objects, functions, maps, is available to the page and its components while that request is rendered, and then it's gone. Use it for request-scoped data, such as the current user, and store anything that must survive between requests in cookies, sessions or a database.

You can add and change properties, but you can't replace the object itself. Astro checks this and throws an error if you assign context.locals = ....

Typing locals

To get autocompletion and type checking for Astro.locals, declare its shape in src/env.d.ts:

// src/env.d.ts
declare namespace App {
  interface Locals {
    requestId: string;
    user: { id: string; name: string } | null;
  }
}

Now both the middleware and every .astro file know that Astro.locals.user is either a user object or null. If you use an integration that adds its own properties to locals, extend its type instead of redefining those properties.

Chaining Middleware With sequence()

Putting everything into one function quickly gets messy. Split it into small functions and join them with sequence() from astro:middleware:

// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';

const timing = defineMiddleware(async (context, next) => {
  const start = performance.now();
  const response = await next();
  response.headers.set('Server-Timing', `total;dur=${(performance.now() - start).toFixed(1)}`);
  return response;
});

const identify = defineMiddleware((context, next) => {
  context.locals.requestId = crypto.randomUUID();
  const session = context.isPrerendered ? undefined : context.cookies.get('session')?.value;
  context.locals.user = session === 'valid-token' ? { id: '1', name: 'Sajjad' } : null;
  return next();
});

const protect = defineMiddleware((context, next) => {
  if (context.url.pathname.startsWith('/account') && !context.locals.user) {
    return context.redirect(`/login?next=${encodeURIComponent(context.url.pathname)}`);
  }
  return next();
});

export const onRequest = sequence(timing, identify, protect);

The session check here is a stand-in to keep the example short. In a real app you'd verify a signed session or look it up in a store. For a full approach, see authentication in Astro and protecting routes.

Execution Order

Each function in a sequence runs its code before next() on the way in and its code after next() on the way out, like layers of an onion. With two logging middleware functions chained as sequence(first, second), a request to /api/time logged:

first request /api/time
second request /api/time
second response
first response

So put middleware that needs the final response, such as timing or header changes, early in the sequence, and middleware that depends on data set by others, such as protect depending on identify, after them.

Protecting Routes

With the middleware above and an on-demand account page:

---
// src/pages/account/index.astro
export const prerender = false;

const { user, requestId } = Astro.locals;
---
<h1>Welcome back, {user?.name}</h1>
<p>Request {requestId}</p>

A request without the cookie is redirected, and a request with it renders the page:

curl -i localhost:4481/account
curl -i localhost:4481/account -H 'Cookie: session=valid-token'
HTTP/1.1 302 Found
location: /login?next=%2Faccount
server-timing: total;dur=1.1

HTTP/1.1 200 OK
server-timing: total;dur=2.9
<h1>Welcome back, Sajjad</h1><p>Request e78f000d-d23d-41ae-8f15-b6299e6b9025</p>

The redirect response also passed back through timing, which is why it has a Server-Timing header. Returning early skips the remaining middleware and the route, but not the middleware that already ran before it.

Returning a Response Directly

Middleware can answer a request itself, which is handy for API keys or maintenance pages:

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware((context, next) => {
  if (
    context.url.pathname.startsWith('/api/') &&
    context.request.headers.get('x-api-key') !== import.meta.env.API_KEY
  ) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }
  return next();
});

Without the header, the endpoint never runs:

{"error":"Unauthorized"} 401

Modifying the Response

Because next() returns the route's Response, you can change it before returning it. Adding headers is the most common case:

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await next();
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  return response;
});

You can also rewrite the body. This example from the Astro docs replaces text in the rendered HTML:

// src/middleware.js
export const onRequest = async (context, next) => {
  const response = await next();
  const html = await response.text();
  const redactedHtml = html.replaceAll('PRIVATE INFO', 'REDACTED');
  return new Response(redactedHtml, {
    status: 200,
    headers: response.headers,
  });
};

Reading the whole body with response.text() waits for the page to finish rendering, so it removes the benefit of HTML streaming. Use it sparingly.

Rewrites

A rewrite renders a different route while keeping the URL in the browser unchanged. Middleware has two ways to do it:

  • context.rewrite(path) starts a new rendering phase for the new path, and all middleware runs again for it.
  • next(path) rewrites the request in place and continues the chain without re-running the middleware that already ran.
// src/middleware.ts
import { sequence } from 'astro:middleware';

async function first(context, next) {
  if (context.url.pathname === '/old-login') {
    return next('/login');
  }
  return next();
}

async function second(context, next) {
  console.log('second request', context.url.pathname);
  return next();
}

export const onRequest = sequence(first, second);

A request to /old-login rendered the login page, and the second function saw the new path:

second request /login

One caution from the docs: rewriting with next(path) creates a new Request from the old one, so the request body can't be read before or after the rewrite. That often breaks form submissions with Astro Actions. In those cases, use Astro.rewrite() in the page instead.

Middleware on Prerendered vs On-Demand Pages

This is the most important behaviour to understand. Middleware runs every time a page is rendered, and prerendered pages are rendered once, during astro build. The build log shows it running for each static route:

 prerendering static routes
  ├─ /about/index.htmlfirst request /about/
second request /about/
second response
first response

The consequences:

  • No request-time logic on static pages: middleware can't protect a prerendered page, read its cookies, or add response headers to it at request time. A request to the prerendered /about/ page came back without the Server-Timing header that on-demand routes got.
  • Request data isn't available: cookies and request headers don't exist during prerendering. The context.isPrerendered check in the identify example avoids reading cookies in that case.
  • locals still works: data you put on locals at build time is available to the static page as it renders.

If a route needs request-time middleware, render it on demand with export const prerender = false, as explained in mixing prerendered and on-demand pages. Some adapters can also run your middleware at the edge in front of static files: the Netlify adapter's middlewareMode: 'edge' runs it for every request, including static assets, and Vercel's runs it for all on-demand routes, including ones cached by ISR.

Error Pages

Middleware also runs for on-demand requests that don't match any route, before the 404 page is rendered. An unknown URL still went through both functions in the test sequence. It runs before 500 error pages too, unless the error happened inside the middleware itself. Whether this happens on your host depends on the adapter, because some platforms serve their own error pages.

Tips for Writing Good Middleware

  1. Keep it fast: it runs for every on-demand request, so avoid slow work for routes that don't need it. Check context.url.pathname first.
  2. Don't forget to return: every path through the function must return next() or a Response.
  3. Use locals instead of global variables: module-level variables are shared between requests on the same server and can leak data between users.
  4. Prefer clear helpers over one large function: small functions joined with sequence() are easier to test and reorder.
  5. Mind actions: if you use Astro Actions, see Astro Actions for gating them in middleware with getActionContext().

FAQ: Astro Middleware

Create src/middleware.ts or src/middleware.js, or src/middleware/index.ts if you want to split it into several files. Export a named function called onRequest. Astro picks it up automatically.

Only at build time. Prerendered pages are rendered once during astro build, so middleware runs then and not when visitors request the page. Render the route on demand if it needs request-time middleware.

Set properties on context.locals in the middleware and read them from Astro.locals in the page or any component. Declare the shape in the App.Locals interface in src/env.d.ts to get types.

Import sequence from astro:middleware and export onRequest as the result of calling it with your functions. They run in the order you pass them on the way in, and in reverse order on the way out.

Calling next with a path rewrites the request in place and continues the current chain without re-running earlier middleware. context.rewrite starts a fresh rendering phase for the new path, so all middleware runs again.

Yes. Return context.redirect with the target path, and optionally a status code. The route is never rendered, and the redirect response passes back through any earlier middleware in the sequence.


Conclusion

Astro middleware is a single onRequest() function in src/middleware.ts that sits in front of your pages and endpoints. It can attach request-scoped data to locals, stop a request with a redirect or a custom response, add headers to the response, and rewrite requests to other routes. defineMiddleware() adds types, and sequence() lets you build the logic from small, ordered pieces.

The main thing to keep in mind is when it runs. On on-demand routes it runs for every request, which makes it the right tool for authentication and headers. On prerendered pages it runs once at build time and never again. Decide which routes need request-time logic, render those on demand, and keep your middleware small and fast.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper