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.
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 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.
Related articles
- Astro Astro Middleware Tutorial
Use Astro middleware to run code on every request: auth gates, locals injection, redirects, and response headers. Learn the onRequest signature, the next() flow, sequencing, and common production patterns.
- Astro Astro Server Endpoints Tutorial
Build JSON APIs and dynamic responses directly in Astro using server endpoints. Learn the file conventions, request and response shapes, dynamic params, and how to mix endpoints with static and SSR pages.
- Astro Astro Islands Architecture Explained
Learn how Astro ships zero JavaScript by default and only hydrates the interactive components you mark as islands.
- Astro Astro vs Next.js Comparison
Compare Astro and Next.js across rendering models, performance defaults, ecosystem, and use cases to pick the right framework for your project.