Astro 5 middleware runs before every HTTP request hits your router. It's the guard rail before pages render. Velocity X uses a single src/middleware.ts to gate /dashboard behind auth, detect user locale, and resolve the tenant from the Host header — all before a single component mounts. Three critical patterns in 60 lines.
If you've been living under a rock, middleware is where you put logic that must run on every request. Not per-route. Not on the client. On the server, before routing. No page guards, no race conditions, no client-side redirects that flash unauthenticated content.
Why Middleware > Per-Page Guards
Per-page guards (checking auth inside a component or in a route's getStaticProps) happen AFTER you've already rendered that component. Middleware runs first. If a user hits /dashboard without a session, middleware redirects them to /login before the dashboard even loads. Faster. Cleaner. No auth flashing.
Middleware is also the right place for tenant resolution and locale detection. Those decisions must happen once, before routing splits. Store them in Astro.locals so every subsequent handler — Actions, page components, API routes — inherits that context for free.
The Architecture: Locals, Redirect Helpers, Sequence
Astro middleware receives a context object. You populate context.locals with auth state, the resolved tenant, the detected locale, and the Supabase client. Then you return context.next() to proceed, or context.redirect() to bounce the user. The sequence matters: auth first (fail fast), then locale, then tenant.
{`// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { createClient } from '@supabase/supabase-js';
export const onRequest = defineMiddleware(async (context, next) => {
const { request, locals } = context;
// 1. Auth: check session and load user from Supabase
const token = request.headers.get('cookie')?.match(/sb-token=([^;]+)/)?.[1];
const supabase = createClient(import.meta.env.PUBLIC_SUPABASE_URL, import.meta.env.PUBLIC_SUPABASE_ANON_KEY);
if (token) {
const { data } = await supabase.auth.getUser(token);
locals.auth = { user: data.user };
} else {
locals.auth = null;
}
// 2. Locale detection: from URL, cookie, or Accept-Language header
const pathname = new URL(request.url).pathname;
let locale = 'en';
if (pathname.startsWith('/fr')) locale = 'fr';
if (pathname.startsWith('/es')) locale = 'es';
locals.locale = locale;
// 3. Tenant resolution: from Host header (multi-tenant SaaS)
const host = request.headers.get('host') || '';
locals.tenant = host.includes('staging') ? 'staging' : 'production';
// 4. Gate /dashboard — auth required
if (pathname.startsWith('/dashboard') && !locals.auth?.user) {
return context.redirect('/login?from=' + encodeURIComponent(pathname));
}
// 5. Inject Supabase client into locals for Actions
locals.supabase = supabase;
return next();
});`}
Now every Action, page, and component can read Astro.locals.auth, Astro.locals.locale, and Astro.locals.tenant without re-detecting or re-authenticating.
Three Real Handlers in Velocity X
Auth gate on /dashboard: Check for a valid session token. If missing, redirect to /login with the original path as a query param so login can redirect back. If the token is stale, Supabase's getUser() returns null and we bounce them. Fast-fail before dashboard renders.
Locale from pathname prefix: If the request hits /fr/..., set locals.locale = 'fr'. Pages and components read this and render French copy. Avoids a second lookup or a cookie round-trip. Clean and explicit in the URL.
Tenant from Host header: Velocity X runs on multiple subdomains (staging.velocity-x.io, api.velocity-x.io, www.velocity-x.io). Middleware reads the Host header and sets locals.tenant. Database queries and Actions can then filter data by tenant automatically — no need to pass it through every function signature.
Six FAQs
Does middleware run on static builds?
No. Middleware runs during SSR and on-demand rendering. Static-generated pages skip middleware. If you statically build every page, middleware never runs. Velocity X uses mostly SSR for auth and multi-tenancy, so middleware is always live.
Can I throw an error in middleware?
Yes. Throw a new Error() and Astro renders a 500. If you want a specific status code, use context.redirect() (302), context.rewrite() (internal rewrite), or throw an AstroError with a custom status. Middleware errors halt the request pipeline.
How do I pass data from middleware to a page component?
Via Astro.locals. Middleware populates locals, and every page reads it. It's not reactive on the client — it's server-side context attached to the request object.
Can middleware call Actions?
No. Actions are server functions called from the client. Middleware is the request guard before routing. If you need to call a database in middleware, use your Supabase client directly (like Velocity X does for session lookup).
Does middleware cache?
No. It runs on every request. If you're doing expensive DB lookups (e.g. tenant resolution), consider caching with Redis or a memory-backed store. Velocity X caches the Host → Tenant mapping for 1 hour.
Can I share middleware across multiple Astro apps?
Yes. Export from an npm package or a shared folder. You're just exporting an async function. If you have 5 microservices and want the same auth logic, pull it into a shared lib and re-export from each app's middleware.ts.
The Bottom Line
Middleware is the invisible scaffolding of modern web apps. Gate auth, detect user context, resolve tenants — all before routing, all once, all stored in one place. Beats scattered route guards and component-level checks. See it live in Velocity X, or dig into Actions for the mutation side at Astro Actions.