Working with Astro's middleware
I like to think of middleware as middle agents.
A simplified flow of data from a user’s browser to our application servers comprises a request-response cycle.
A middleware takes the front-row seat in the application server. Allowing us to intercept the user request before it is completed.
With a middleware, we can modify the client request via headers or cookies.
We can also forward the request as is, but inject a new behaviour e.g., log the requests.
Middleware is also capable of just returning a response to the client before hitting any reaching actors in the web server e.g., handling request validations.
As we explore middleware, note that middleware is invoked every time a page or endpoint is about to be rendered.
The best way to get acquainted with Astro middleware is to try examples. Let’s get started with a basic one.
Hello world, middleware
What would a basic Astro middleware look like?
Start a new Astro project with the following command:
npm create astro@latest -- --yes --template=minimal --skip-houston hello-world
This will use hello-world
as the project directory and use the minimal
Astro starter template.
Run the project:
cd hello-world && npm run dev
To use a middleware, create a new file in src/middleware.js|ts
. Alternatively, we may use a middleware
directory e.g.: src/middleware/index.js|ts
.
Let’s stick to the simpler src/middleware.ts
file.
In this file, make sure to export an onRequest
function that points to the middleware.
import type { MiddlewareHandler } from "astro";
const middleware = () => {};
// Export onRequest. This should NOT be a default export
export const onRequest = middleware;
The middleware is incomplete.
Technically speaking, a middleware will be invoked with two arguments and must return a Response object.
Here’s a typed implementation:
import type { MiddlewareResponseHandler } from "astro";
import { defineMiddleware } from "astro/middleware";
const middleware: MiddlewareResponseHandler = (context, next) => {
};
export const onRequest = middleware;
The context object is identical to the endpoint context which mirrors many of the global Astro properties. The next
function returns a standard Response
when invoked.
Now, complete the basic middleware as shown below:
const middleware: MiddlewareResponseHandler = (context, next) => {
console.log("HELLO WORLD!");
return next();
};
In this example, the middleware receives a request, logs HELLO WORLD!
and returns an unmodified response that continues its standard lifecycle.
Now, go to the project’s index page and add a simple log as shown below:
---
console.log("Rendering the index page");
---
If you inspect the local server logs, every time the index page is rendered, the middleware log will be fired first. Hit refresh to see multiple logs.
A terser version of the same middleware could also be written as shown below:
{/* 📂 src/middleware.ts */}
import { defineMiddleware } from "astro/middleware";
/**
* Inline the middleware function by leveraging "defineMiddleware"
*/
export const onRequest = defineMiddleware((context, next) => {
console.log("HELLO WORLD!");
return next();
});
Middleware responses
Our current Hello World middleware simply intercepts the user’s request, logs a message on the server and forwards the response by calling next()
.
In this example, we’re not modifying the user response.
next()
returns a standard response that continues its lifecycle as usual i.e., eventually renders the requested page or endpoint.
Consider the following example that intercepts the user request and immediately returns a response to the user.
{/* 📂 src/middleware.ts */}
import { defineMiddleware } from "astro/middleware";
export const onRequest = defineMiddleware((context, next) => {
console.log("MIDDLEWARE");
return new Response(
JSON.stringify({
message: "Hello world",
}),
{
status: 200,
}
);
});
Now, if you visit the index
page (or any valid route), you will no longer receive the requested resource, but the JSON
response specified in the middleware.
Why Middleware?
Middleware shines when you need centralised logic for your application.
Remember, every request to pages and API endpoints in your project will be intercepted by middleware.
Invariably, middleware acts as the first touchpoint to your application, making it suitable for centralised logic such as:
- Authentication
- A/B testing
- Server-side validations
- Request logging
And much more.
Instead of duplicating logic across different pages and endpoints, centralise shared application logic or behaviour with middleware.
Practical Astro Middleware Examples
Beyond Hello World, let’s take a plunge into practical middleware use cases. We’ll start simple and increasingly progress in complexity.
Redirects
Use Response.redirect
or context.redirect
to respond with a redirect within a middleware.
The API is shown below:
// Response.redirect
return Response.redirect(url: URL | string, status?: number)
// For SSR:
return context.redirect(path: string, status?: number)
The main difference between both APIs is that context.redirect
accepts relative string paths e.g., /redirected
while Response.redirect
accepts either a URL object or the full redirect URL path e.g., https://www.example.com/redirected
not /redirected
.
Let’s consider a practical example.
Start a new Astro project with the command below:
npm create astro@latest -- --yes --skip-houston --template=basics redirect
Create a new src/pages/redirected.astro
page.
Copy the entire content of src/pages/index.astro
into this new page and change the <h1>
element to the following:
<h1>You've been <span class="text-gradient">Redirected</span></h1>
Now handle the redirect in the middleware as shown below:
// src/middleware.ts
import { defineMiddleware } from "astro/middleware";
const INDEX_PATH = "/";
export const onRequest = defineMiddleware((context, next) => {
/**
* The middleware runs every time a page or endpoint is about to be rendered.
* Only redirect if this is the home page
*/
if (context.url.pathname === INDEX_PATH) {
/**
* Construct a full URL by passing `context.url` as the base URL
*/
return Response.redirect(new URL("/redirected", context.url), 302);
/**
* You may also redirect using `context.redirect` as shown below:
* =========================================
* return context.redirect("/redirected", 302);
* =========================================
* Note that this only works in SSR mode
*/
}
return next();
});
How to use Astro.locals
For the entire lifecycle of a request i.e., from when it’s received by the middleware to when the eventual response is sent to the user, we may persist data in the locals object to be used in Astro pages, API endpoints or other middleware.
Consider the example below:
// src/middleware.ts
import { defineMiddleware } from "astro/middleware";
export const onRequest = defineMiddleware((context, next) => {
// add a string value to the locals object
context.locals.stringValue = "Hello Middleware";
// add a method to the locals object
context.locals.functionValue = () => "This is a function return value";
return next();
});
We’ve added two values to the locals
object, and may access this on an Astro page as shown below:
// src/pages/index.astro
---
const data = Astro.locals;
console.log({ data });
/**
* Log ⬇️
* {
data: {
stringValue: 'Hello Middleware',
functionValue: [Function (anonymous)]
}
}
*/
console.log({ res: data.functionValue() });
/**
* Log ⬇️
* { res: 'This is a function return value' }
*/
---
For full typescript support, update the src/env.d.ts
file to include the specific locals
properties as shown below:
// Before
/// <reference types="astro/client" />
// After
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
stringValue: string;
functionValue: () => string;
}
}
How to use multiple Astro middleware
In our previous examples, we only considered an application with one middleware. In reality, we often use a series of middleware, where the user’s request is passed from one middleware to the next until a response is finally sent back to the user.
Create a new Astro project and create the following two middleware:
// src/middleware/auth.ts
import { defineMiddleware } from "astro/middleware";
export const auth = defineMiddleware((context, next) => {
console.log("In auth middleware");
return next();
});
// src/middleware/validate.ts
import { defineMiddleware } from "astro/middleware";
export const validate = defineMiddleware((context, next) => {
console.log("In validate middleware");
return next();
});
Note that these are created in a middleware
directory.
Instead of creating a directory, we could inline the different middleware in a single src/middleware.ts
file. However, having a directory is arguably neater when working with multiple middleware.
Now create an src/middleware/index.ts
file to compose the multiple middleware as shown below:
// src/middleware/index.ts
// sequence will accept middleware functions and will execute them in the order they are passed
import { sequence } from "astro/middleware";
// import the middleware
import { auth } from "./auth";
import { validate } from "./validate";
// export onRequest. Invoke "sequence" with the middleware
export const onRequest = sequence(validate, auth);
With this, look in the server logs, and you’ll find logs from the validate and auth middleware.
Please note that the order in which you pass the middleware to sequence
matters. For example, consider the change below:
// Before
export const onRequest = sequence(validate, auth);
// After
export const onRequest = sequence(auth, validate);
This will result in a different execution order as seen in the logs:
Basic Authentication
Basic authentication provides a way for a browser to provide a username and password when making a request.
Consider the basic auth example with Astro middleware shown below:
import { defineMiddleware } from "astro/middleware";
export const onRequest = defineMiddleware((context, next) => {
// If present, this will have the form: "Basic <credential>"
const basicAuth = context.request.headers.get("authorization");
if (basicAuth) {
// get auth value from string "Basic authValue"
const authValue = basicAuth.split(" ")[1];
// decode the Base64 encoded string via atob (https://developer.mozilla.org/en-US/docs/Web/API/atob)
const [user, pwd] = atob(authValue).split(":");
if (user === "admin" && pwd === "admin") {
// forward request
return next();
}
}
return new Response("Auth required", {
status: 401,
headers: {
"WWW-authenticate": 'Basic realm="Secure Area"',
},
});
});
The crux of the solution is if the browser requests with an authorisation header, we respond with the following:
return new Response("Auth required", {
status: 401,
headers: {
"WWW-authenticate": 'Basic realm="Secure Area"',
},
});
The browser receives this response and understands to request basic authorisation by showing the username and password prompt.
Once a user enters the username and password, it’s sent to the server as a header. We then parse the request accordingly in the middleware as shown below:
const basicAuth = context.request.headers.get("authorization");
if (basicAuth) {
// Get auth value from string "Basic authValue"
const authValue = basicAuth.split(" ")[1];
// Decode the Base64 encoded string via atob (https://developer.mozilla.org/en-US/docs/Web/API/atob)
const [user, pwd] = atob(authValue).split(":");
// Our username and password must be "admin" to be valid
if (user === "admin" && pwd === "admin") {
// forward request
return next();
}
}
NB: By default, basic auth remains cached until the browser is closed i.e., the user remains logged in till they close the browser.
JWT Authentication via Astro middleware
JSON Web Tokens are a common way to communicate authentication claims between server and client.
Let’s see an implementation in an Astro middleware.
First, create a new project by running the following command:
npm create astro@latest -- --yes --skip-houston --template=basics jwt-auth
Now, create a new src/constants.ts
file with the following content:
// The key of the JWT cookie value
export const TOKEN = "token";
// The following pages do NOT need auth to be accessed
export const PUBLIC_ROUTES = ["/", "/api/login", "/api/logout"];
Create an endpoint route for /api/login
to log in a user.
// src/pages/api/login.ts
import { nanoid } from "nanoid";
import { SignJWT } from "jose";
import type { APIRoute } from "astro";
import { TOKEN } from "../../constant";
// The token secret. Note the environment variable "JWT_SECRET_KEY
// @see https://docs.astro.build/en/guides/environment-variables/
const secret = new TextEncoder().encode(import.meta.env.JWT_SECRET_KEY);
export const post: APIRoute = async (ctx) => {
try {
// Create the token
// @see https://github.com/panva/jose
const token = await new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setJti(nanoid())
.setIssuedAt()
.setExpirationTime("2h")
.sign(secret);
// set JWT as a cookie
ctx.cookies.set(TOKEN, token, {
httpOnly: true,
path: "/",
maxAge: 60 * 60 * 2, // 2 hours in seconds
});
// return a successful response
return new Response(
JSON.stringify({
message: "You're logged in!",
}),
{
status: 200,
}
);
} catch (error) {
console.debug(error);
return new Response(
JSON.stringify({
message: "Login failed",
}),
{
status: 500,
}
);
}
};
If a user’s logged in, they should be able to visit any protected routes in the application.
Let’s handle the auth validation via a middleware. Create one in src/middleware.ts
and consider the annotated content below:
// src/middleware.ts
import { errors, jwtVerify } from "jose";
import { defineMiddleware } from "astro/middleware";
import { TOKEN, PUBLIC_ROUTES } from "./constant";
// The JWT secret
const secret = new TextEncoder().encode(import.meta.env.JWT_SECRET_KEY);
/**
* Verify if a client token.
*/
const verifyAuth = async (token?: string) => {
if (!token) {
return {
status: "unauthorized",
msg: "Please pass a request token",
} as const;
}
try {
const jwtVerifyResult = await jwtVerify(token, secret);
return {
status: "authorized",
payload: jwtVerifyResult.payload,
msg: "successfully verified auth token",
} as const;
} catch (err) {
if (err instanceof errors.JOSEError) {
return { status: "error", msg: err.message } as const;
}
console.debug(err);
return { status: "error", msg: "could not validate auth token" } as const;
}
};
export const onRequest = defineMiddleware(async (context, next) => {
// Ignore auth validation for public routes
if (PUBLIC_ROUTES.includes(context.url.pathname)) {
// Respond as usual
return next();
}
// Get the token from cookies
const token = context.cookies.get(TOKEN).value;
// Verify the token
const validationResult = await verifyAuth(token);
console.log(validationResult);
// Handle the validation result
switch (validationResult.status) {
case "authorized":
// Respond as usual if the user is authorised
return next();
case "error":
case "unauthorized":
// If an API endpoint, return a JSON response
if (context.url.pathname.startsWith("/api/")) {
return new Response(JSON.stringify({ message: validationResult.msg }), {
status: 401,
});
}
// Otherwise, this is a standard page. Redirect to the root page for the user to login
else {
return Response.redirect(new URL("/", context.url));
}
default:
return Response.redirect(new URL("/", context.url));
}
});
Feature flags with Astro middleware
Feature flags enable easy rollback of a new feature or code. They can also be used to control access to certain parts of your application, all without the need for redeployment.
Handling feature flags via Astro middleware is straightforward depending on the service where your feature flags are hosted.
Assume we want to redirect users to a special marketing page if the feature flag astro-middleware-demo is toggled on. Regardless of your feature flag service, your implementation will look something like the following:
// 📂 src/middleware.ts
import { defineMiddleware } from "astro:middleware";
/** Import the Feature Flag client - make sure this is server compatible e.g., node js client **/
import { FeatureFlagger } from "some-feature-node-library";
const HOME_PAGE_PATH = "/";
/**
* Depending on your service's API:
* Create the feature flag client and pass your project API key
* Add FEATURE_FLAG_API_KEY to "src/.env"
*/
const client = new FeatureFlagger(import.meta.env.FEATURE_FLAG_API_KEY);
/** 👀 The middleware function **/
export const onRequest = defineMiddleware(async (context, next) => {
/**
* Early return. We will only check the feature flag for requests
* to the homepage
*/
if (context.url.pathname !== HOME_PAGE_PATH) {
return next();
}
try {
/**
* Retrieve the feature toggle for your feature flag
* In this case, "astro-middleware-demo"
*/
const isEnabled = await client.isFeatureEnabled(
"astro-middleware-demo",
);
if (isEnabled) {
console.log("Feature is ENABLED!");
/**
* When the feature flag is toggled on, redirect users who access the homepage,
* to the "/astro-middleware-demo" page
*/
return Response.redirect(new URL("/astro-middleware-demo", context.url), 302);
/**
* Otherwise, handle the request as usual
*/
return next();
}
/**
* Feature flag NOT enabled? Handle the request as usual
*/
console.log("Feature is DISABLED!");
return next();
} catch (error) {
console.error("Failed to load feature flag from some-feature-node-library");
/**
* Handle the request as usual
*/
return next();
}
});
Conclusion
Astro is now closer to parity with other mainstream frameworks like NextJS, thanks to its middleware support. Middleware in web client frameworks (regardless of which) helps centralize logic within your application and aims to solve the same problem.
It's important to note that Astro only supports a single global middleware.ts file, and does not allow for route-specific middleware. However, if you're deploying Astro on Vercel, you can take advantage of the matcher configuration available to NextJS middleware.
// src/middleware.ts
export const config = {
// Only run the middleware on the marketing route
matcher: '/marketing'
}
// write Middleware as usual
Visit the Github repo for all the examples discussed here and more. See Astro middleware examples.