Working with Astro's middleware

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 basic request-response cycle
A basic 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.

The middleware intercepts the client request
The middleware intercepts the client request

With a middleware, we can modify the client request via headers or cookies.

A middleware can modify the client request
A middleware can modify the client request

We can also forward the request as is, but inject a new behaviour e.g., log the requests.

A middleware can inject new behaviour
A middleware can inject new behaviour

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.

A middleware can respond directly to a client request
A middleware can respond directly to a client request

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
The new running application
The new running application

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.

The Hello world middleware logs
The Hello world middleware 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.

Intercepted request and middleware response
Intercepted request and middleware response

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.

Using multiple middleware in as Astro project
Using multiple middleware in as Astro project

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.

The directory structure for multiple middleware
The directory structure for multiple middleware

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.

The multiple middleware logs
The multiple middleware logs

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:

The reordered multiple middleware logs
The reordered multiple middleware logs


Learn the secret(s) of Astro 


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"',
    },
  });
});
Basic authentication interface.
Basic authentication interface.

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.

Learn the secret(s) of Astro