Skip to main content
Technology & EngineeringAstro355 lines

Astro Middleware

Middleware patterns in Astro for authentication, request modification, response headers, and shared context

Quick Summary16 lines
You are an expert in Astro middleware for intercepting requests, handling authentication, and sharing data between middleware and pages.

## Key Points

- Use `sequence()` to compose focused, single-responsibility middleware functions rather than one large middleware.
- Type `App.Locals` in `src/env.d.ts` so that every page and endpoint has autocompletion for locals.
- Keep middleware fast. Heavy operations (database queries, external API calls) should be cached or deferred when possible.
- Only run authentication checks on routes that need them. Check the pathname early and return `next()` for public routes to avoid unnecessary work.
- Use middleware for cross-cutting concerns (auth, headers, logging, i18n) and keep business logic in pages and endpoints.
- **Forgetting to return `next()` or a Response**: If middleware neither calls `next()` nor returns a Response, the request hangs indefinitely.
- **Mutating the response body**: You cannot easily read and modify the HTML body of a response in middleware. Use `astro:before-swap` on the client or an integration hook for HTML transformations.
- **Not handling CORS preflight**: For API routes, `OPTIONS` requests need an explicit handler. Middleware can catch these, but you must return the right headers before calling `next()`.
- **Rate limiting in serverless**: The in-memory rate limiter shown above resets on every cold start. For serverless deployments, use an external store like Redis or a platform-provided rate limiter.
- **Middleware ordering with `sequence`**: Middleware runs in the order listed. If the auth middleware depends on a request-ID set by the logging middleware, make sure logging comes first.
skilldb get astro-skills/Astro MiddlewareFull skill: 355 lines
Paste into your CLAUDE.md or agent config

Middleware — Astro

You are an expert in Astro middleware for intercepting requests, handling authentication, and sharing data between middleware and pages.

Overview

Astro middleware lets you intercept requests and responses on every page load and API endpoint call. Middleware runs on the server before your page or endpoint code and can modify the request, set shared data via locals, redirect unauthenticated users, add response headers, and more. Middleware is defined in src/middleware.ts.

Core Concepts

Basic Middleware

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

export const onRequest = defineMiddleware(async (context, next) => {
  // Runs before the page/endpoint handler
  console.log(`Request: ${context.request.method} ${context.url.pathname}`);

  // Call next() to continue to the page/endpoint
  const response = await next();

  // Runs after the page/endpoint handler
  // You can modify the response here
  return response;
});

The Context Object

Middleware receives the same context available in pages and endpoints:

export const onRequest = defineMiddleware(async (context, next) => {
  context.url;            // URL object for the request
  context.request;        // Standard Request object
  context.params;         // Dynamic route parameters
  context.cookies;        // Cookie helper (get, set, delete)
  context.locals;         // Mutable object shared with pages/endpoints
  context.clientAddress;  // Client IP address
  context.redirect(url);  // Return a redirect response

  return next();
});

Setting Locals

context.locals is the primary way to pass data from middleware to pages and endpoints:

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

export const onRequest = defineMiddleware(async (context, next) => {
  const token = context.cookies.get('auth_token')?.value;

  if (token) {
    try {
      const user = await verifyToken(token);
      context.locals.user = user;
      context.locals.isAuthenticated = true;
    } catch {
      context.locals.user = null;
      context.locals.isAuthenticated = false;
    }
  } else {
    context.locals.user = null;
    context.locals.isAuthenticated = false;
  }

  return next();
});

Access locals in any page or endpoint:

---
// src/pages/dashboard.astro
const { user, isAuthenticated } = Astro.locals;

if (!isAuthenticated) {
  return Astro.redirect('/login');
}
---

<h1>Welcome, {user.name}</h1>

Typing Locals

Define the shape of locals for full TypeScript support:

// src/env.d.ts
declare namespace App {
  interface Locals {
    user: {
      id: string;
      name: string;
      email: string;
      role: 'admin' | 'user';
    } | null;
    isAuthenticated: boolean;
    requestId: string;
  }
}

Implementation Patterns

Authentication Guard

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

const protectedRoutes = ['/dashboard', '/settings', '/admin'];

export const onRequest = defineMiddleware(async (context, next) => {
  const isProtected = protectedRoutes.some(route =>
    context.url.pathname.startsWith(route)
  );

  if (isProtected) {
    const token = context.cookies.get('session')?.value;

    if (!token) {
      return context.redirect(`/login?redirect=${context.url.pathname}`);
    }

    try {
      const user = await verifySession(token);
      context.locals.user = user;
    } catch {
      context.cookies.delete('session');
      return context.redirect('/login');
    }
  }

  return next();
});

Role-Based Access Control

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

const roleRoutes: Record<string, string[]> = {
  '/admin': ['admin'],
  '/editor': ['admin', 'editor'],
};

export const onRequest = defineMiddleware(async (context, next) => {
  // Authentication (populate locals.user)
  const token = context.cookies.get('session')?.value;
  if (token) {
    context.locals.user = await verifySession(token);
  }

  // Authorization
  for (const [prefix, allowedRoles] of Object.entries(roleRoutes)) {
    if (context.url.pathname.startsWith(prefix)) {
      const user = context.locals.user;
      if (!user || !allowedRoles.includes(user.role)) {
        return new Response('Forbidden', { status: 403 });
      }
    }
  }

  return next();
});

Adding Response Headers

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

export const onRequest = defineMiddleware(async (context, next) => {
  const response = await next();

  // Security headers
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-XSS-Protection', '1; mode=block');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=()'
  );

  // CORS for API routes
  if (context.url.pathname.startsWith('/api/')) {
    response.headers.set('Access-Control-Allow-Origin', 'https://example.com');
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }

  return response;
});

Request Logging

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

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

  const start = performance.now();

  const response = await next();

  const duration = Math.round(performance.now() - start);
  console.log(
    JSON.stringify({
      requestId,
      method: context.request.method,
      path: context.url.pathname,
      status: response.status,
      duration: `${duration}ms`,
      userAgent: context.request.headers.get('user-agent'),
    })
  );

  response.headers.set('X-Request-Id', requestId);
  return response;
});

Middleware Chaining with sequence

Compose multiple middleware functions using sequence:

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

const logging = defineMiddleware(async (context, next) => {
  const start = Date.now();
  const response = await next();
  console.log(`${context.url.pathname} - ${Date.now() - start}ms`);
  return response;
});

const auth = defineMiddleware(async (context, next) => {
  const token = context.cookies.get('session')?.value;
  if (token) {
    context.locals.user = await verifySession(token);
  }
  return next();
});

const i18n = defineMiddleware(async (context, next) => {
  const lang = context.cookies.get('lang')?.value
    || context.request.headers.get('accept-language')?.split(',')[0]?.split('-')[0]
    || 'en';
  context.locals.lang = lang;
  return next();
});

// Runs in order: logging -> auth -> i18n
export const onRequest = sequence(logging, auth, i18n);

Rate Limiting

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

const rateLimit = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60_000; // 1 minute
const MAX_REQUESTS = 100;

export const onRequest = defineMiddleware(async (context, next) => {
  if (!context.url.pathname.startsWith('/api/')) {
    return next();
  }

  const ip = context.clientAddress;
  const now = Date.now();
  const entry = rateLimit.get(ip);

  if (!entry || now > entry.resetAt) {
    rateLimit.set(ip, { count: 1, resetAt: now + WINDOW_MS });
  } else {
    entry.count++;
    if (entry.count > MAX_REQUESTS) {
      return new Response(JSON.stringify({ error: 'Too many requests' }), {
        status: 429,
        headers: {
          'Content-Type': 'application/json',
          'Retry-After': String(Math.ceil((entry.resetAt - now) / 1000)),
        },
      });
    }
  }

  return next();
});

Core Philosophy

Middleware in Astro occupies a deliberate position in the request lifecycle: it runs on the server before any page or endpoint handler, making it the right place for cross-cutting concerns that apply uniformly across routes. Authentication, request logging, security headers, and internationalization are natural fits. Business logic, data transformation, and content rendering are not.

The locals pattern is Astro's answer to dependency injection for the request lifecycle. Rather than passing data through global state or module-scoped variables, middleware attaches request-specific values to context.locals, which flows cleanly into every page and endpoint. This makes the data flow explicit and testable: you can see exactly what each middleware contributes and what each page consumes.

The sequence utility encourages decomposition into small, single-purpose middleware functions. Each function does one thing: check auth, set headers, log timing, detect locale. Composing them with sequence makes the execution order explicit and the behavior of each layer independently verifiable. This is far preferable to a monolithic middleware function that grows to handle dozens of concerns in one place.

Anti-Patterns

  • Performing heavy computation in middleware. Database queries, external API calls, or complex transformations in middleware run on every request, including pre-rendered pages during build. Keep middleware fast and defer expensive work to specific loaders or endpoints.

  • Using middleware for business logic. Middleware should handle infrastructure concerns (auth, headers, logging), not decide what content to show or how to process form data. Route-specific logic belongs in pages and endpoints.

  • Mutating the response body in middleware. Astro middleware can set headers and return redirects but is not designed for HTML transformation. Attempting to read and rewrite the response body leads to complex, fragile code. Use integration hooks or client-side events for HTML manipulation.

  • Forgetting to call next() or return a Response. A middleware that neither calls next() nor returns a Response causes the request to hang indefinitely. Every code path must terminate with one or the other.

  • Applying auth checks to every route indiscriminately. Running authentication logic on public pages, static assets, and API health checks wastes resources. Check the pathname early and return next() for routes that do not need protection.

Best Practices

  • Use sequence() to compose focused, single-responsibility middleware functions rather than one large middleware.
  • Type App.Locals in src/env.d.ts so that every page and endpoint has autocompletion for locals.
  • Keep middleware fast. Heavy operations (database queries, external API calls) should be cached or deferred when possible.
  • Only run authentication checks on routes that need them. Check the pathname early and return next() for public routes to avoid unnecessary work.
  • Use middleware for cross-cutting concerns (auth, headers, logging, i18n) and keep business logic in pages and endpoints.

Common Pitfalls

  • Middleware runs on pre-rendered pages at build time: During astro build, middleware executes for pre-rendered pages. Avoid relying on runtime-only features (like real cookies or IP addresses) for static pages.
  • Forgetting to return next() or a Response: If middleware neither calls next() nor returns a Response, the request hangs indefinitely.
  • Mutating the response body: You cannot easily read and modify the HTML body of a response in middleware. Use astro:before-swap on the client or an integration hook for HTML transformations.
  • Not handling CORS preflight: For API routes, OPTIONS requests need an explicit handler. Middleware can catch these, but you must return the right headers before calling next().
  • Rate limiting in serverless: The in-memory rate limiter shown above resets on every cold start. For serverless deployments, use an external store like Redis or a platform-provided rate limiter.
  • Middleware ordering with sequence: Middleware runs in the order listed. If the auth middleware depends on a request-ID set by the logging middleware, make sure logging comes first.

Install this skill directly: skilldb add astro-skills

Get CLI access →