Skip to content
Codeloom
Astro

Astro Middleware and API Endpoints in Practice

Combine Astro middleware with server endpoints to build request pipelines: auth gates, rate limiting, CORS handling, and JSON APIs that share context through Astro.locals.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • How middleware and endpoints work together in a request pipeline
  • How to chain multiple middleware functions with sequence()
  • How to build JSON API endpoints with proper error handling
  • How to share context between middleware and endpoints via Astro.locals
  • Production patterns: rate limiting, CORS, request logging

Prerequisites

  • Astro project with server or hybrid output mode
  • Basic HTTP and REST concepts

The Request Pipeline

In Astro, every server-rendered request follows this path: middleware runs first, then the page or endpoint handler runs. Middleware can inspect the request, modify Astro.locals, short-circuit with a response, or modify the response after the handler finishes. Endpoints are files in src/pages/ that export HTTP method functions (GET, POST, etc.) instead of rendering HTML.

Together, they let you build backend logic directly in your Astro project without a separate API server.

Request → Middleware 1 → Middleware 2 → Endpoint/Page → Response
            ↕                ↕              ↕
        Astro.locals    Astro.locals    Astro.locals
Request pipeline

Middleware Fundamentals

Create src/middleware.ts and export an onRequest function:

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

export const onRequest = defineMiddleware(async (context, next) => {
  const start = performance.now();

  // Run the page or endpoint handler
  const response = await next();

  // Modify the response on the way out
  const duration = Math.round(performance.now() - start);
  response.headers.set('X-Response-Time', `${duration}ms`);
  response.headers.set('X-Request-Id', crypto.randomUUID());

  return response;
});

Chaining Multiple Middleware

When you have several concerns — auth, logging, CORS — use sequence() to compose them:

// src/middleware.ts
import { sequence } from 'astro:middleware';
import { corsMiddleware } from './middleware/cors';
import { authMiddleware } from './middleware/auth';
import { loggingMiddleware } from './middleware/logging';

export const onRequest = sequence(
  corsMiddleware,
  loggingMiddleware,
  authMiddleware,
);

Each middleware file follows the same pattern:

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

export const loggingMiddleware = defineMiddleware(async (context, next) => {
  const { method, url } = context.request;
  console.log(`[${new Date().toISOString()}] ${method} ${url}`);

  const response = await next();

  console.log(`[${new Date().toISOString()}] ${method} ${url} → ${response.status}`);
  return response;
});

Sharing Context via Astro.locals

Astro.locals is a mutable object that middleware can write to and endpoints/pages can read from. It is typed, so you declare its shape once:

// src/env.d.ts
declare namespace App {
  interface Locals {
    user: { id: string; email: string; role: string } | null;
    requestId: string;
    rateLimit: { remaining: number; resetAt: Date };
  }
}

Middleware populates it:

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

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

  if (token) {
    try {
      // Verify the session token against your database or JWT
      const payload = await verifySession(token);
      context.locals.user = {
        id: payload.sub,
        email: payload.email,
        role: payload.role,
      };
    } catch {
      context.locals.user = null;
      context.cookies.delete('session', { path: '/' });
    }
  } else {
    context.locals.user = null;
  }

  return next();
});

async function verifySession(token: string) {
  // Replace with your actual session verification
  const res = await fetch('https://auth.example.com/verify', {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error('Invalid session');
  return res.json();
}

Endpoints read it:

// src/pages/api/profile.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ locals }) => {
  if (!locals.user) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  return new Response(JSON.stringify({ user: locals.user }), {
    headers: { 'Content-Type': 'application/json' },
  });
};

Building API Endpoints

Basic CRUD Endpoint

// src/pages/api/posts/index.ts
import type { APIRoute } from 'astro';
import { db } from '@/lib/database';

export const GET: APIRoute = async ({ url }) => {
  const page = Number(url.searchParams.get('page') ?? '1');
  const limit = Math.min(Number(url.searchParams.get('limit') ?? '20'), 100);
  const offset = (page - 1) * limit;

  const posts = await db.query(
    'SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT $1 OFFSET $2',
    [limit, offset],
  );

  return new Response(JSON.stringify({ posts, page, limit }), {
    headers: { 'Content-Type': 'application/json' },
  });
};

export const POST: APIRoute = async ({ request, locals }) => {
  if (!locals.user) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  const body = await request.json();

  if (!body.title || typeof body.title !== 'string') {
    return new Response(JSON.stringify({ error: 'Title is required' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  const post = await db.query(
    'INSERT INTO posts (title, body, author_id) VALUES ($1, $2, $3) RETURNING *',
    [body.title, body.body ?? '', locals.user.id],
  );

  return new Response(JSON.stringify(post), {
    status: 201,
    headers: { 'Content-Type': 'application/json' },
  });
};

Dynamic Route Endpoint

// src/pages/api/posts/[id].ts
import type { APIRoute } from 'astro';
import { db } from '@/lib/database';

export const GET: APIRoute = async ({ params }) => {
  const post = await db.query('SELECT * FROM posts WHERE id = $1', [params.id]);

  if (!post) {
    return new Response(JSON.stringify({ error: 'Not found' }), {
      status: 404,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  return new Response(JSON.stringify(post), {
    headers: { 'Content-Type': 'application/json' },
  });
};

export const DELETE: APIRoute = async ({ params, locals }) => {
  if (!locals.user || locals.user.role !== 'admin') {
    return new Response(JSON.stringify({ error: 'Forbidden' }), {
      status: 403,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  await db.query('DELETE FROM posts WHERE id = $1', [params.id]);

  return new Response(null, { status: 204 });
};

Response Helpers

Writing new Response(JSON.stringify(...)) with headers everywhere is tedious. Create a helper:

// src/lib/api-response.ts
export function json(data: unknown, status = 200, headers: Record<string, string> = {}) {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      'Content-Type': 'application/json',
      ...headers,
    },
  });
}

export function error(message: string, status = 400) {
  return json({ error: message }, status);
}

export function noContent() {
  return new Response(null, { status: 204 });
}

Now endpoints become cleaner:

import { json, error, noContent } from '@/lib/api-response';

export const GET: APIRoute = async ({ params }) => {
  const post = await db.query('SELECT * FROM posts WHERE id = $1', [params.id]);
  if (!post) return error('Not found', 404);
  return json(post);
};

CORS Middleware

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

const ALLOWED_ORIGINS = ['https://example.com', 'http://localhost:4321'];

export const corsMiddleware = defineMiddleware(async (context, next) => {
  const origin = context.request.headers.get('Origin');

  // Handle preflight
  if (context.request.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin ?? '') ? origin! : '',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
    });
  }

  const response = await next();

  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Credentials', 'true');
  }

  return response;
});

Rate Limiting Middleware

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

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

export const rateLimitMiddleware = defineMiddleware(async (context, next) => {
  // Only rate-limit API routes
  if (!context.url.pathname.startsWith('/api/')) {
    return next();
  }

  const ip = context.request.headers.get('x-forwarded-for') ?? 'unknown';
  const now = Date.now();
  const record = requests.get(ip);

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

  const current = requests.get(ip)!;
  context.locals.rateLimit = {
    remaining: MAX_REQUESTS - current.count,
    resetAt: new Date(current.resetAt),
  };

  const response = await next();
  response.headers.set('X-RateLimit-Remaining', String(context.locals.rateLimit.remaining));

  return response;
});

Protecting Routes in Middleware

Instead of checking locals.user in every endpoint, protect entire route prefixes in middleware:

// src/middleware/auth.ts
const PROTECTED_PREFIXES = ['/dashboard', '/api/admin', '/settings'];

export const authMiddleware = defineMiddleware(async (context, next) => {
  // ... (token verification from earlier)

  const isProtected = PROTECTED_PREFIXES.some((prefix) =>
    context.url.pathname.startsWith(prefix),
  );

  if (isProtected && !context.locals.user) {
    // For API routes, return 401
    if (context.url.pathname.startsWith('/api/')) {
      return new Response(JSON.stringify({ error: 'Unauthorized' }), {
        status: 401,
        headers: { 'Content-Type': 'application/json' },
      });
    }
    // For pages, redirect to login
    return context.redirect('/login?redirect=' + encodeURIComponent(context.url.pathname));
  }

  return next();
});

Error Handling

Wrap endpoint logic in try-catch and return structured errors:

// src/pages/api/webhook.ts
import type { APIRoute } from 'astro';
import { json, error } from '@/lib/api-response';

export const POST: APIRoute = async ({ request }) => {
  try {
    const body = await request.json();
    const result = await processWebhook(body);
    return json({ success: true, result });
  } catch (err) {
    if (err instanceof SyntaxError) {
      return error('Invalid JSON body', 400);
    }
    console.error('Webhook processing failed:', err);
    return error('Internal server error', 500);
  }
};

Summary

Middleware and endpoints turn Astro from a static site generator into a full-stack framework. Middleware handles cross-cutting concerns — auth, logging, CORS, rate limiting — in one place. Endpoints give you typed API routes with access to the same Astro.locals that middleware populates. The sequence() function keeps middleware composable and ordered. And response helpers keep the boilerplate down so you can focus on the logic.