Middleware
There's no middleware.ts file. Instead, Elysia gives you lifecycle hooks: and they're more powerful than traditional middleware.
#Middleware in AbsoluteJS
If you're coming from Next.js or Express, you might be looking for a middleware layer. In AbsoluteJS, your server is an Elysia server: and Elysia uses lifecycle hooks instead of middleware. They run at specific phases of the request, giving you more control over when your code executes.
Next.js middleware.ts
Single file, runs before every request on the edge. Can redirect, rewrite, set headers, check auth.
Elysia Lifecycle Hooks
Multiple hooks at specific phases. onRequest, onBeforeHandle, guard, and more. Runs on Bun, not edge.
// Next.js middleware pattern:
// └── middleware.ts (single file, runs before every request)
// AbsoluteJS / Elysia equivalent:
// └── Lifecycle hooks : more granular, more powerful
new Elysia()
// onRequest : runs on EVERY request (like Next.js middleware)
.onRequest(({ set }) => {
set.headers['X-Powered-By'] = 'AbsoluteJS';
})
// onBeforeHandle : runs after validation, before handler
// This is where auth checks, redirects, and access control go
.onBeforeHandle(({ cookie, status }) => {
if (!cookie.session.value) return status(401);
})
// guard : scope hooks to specific route groups
.guard(
{ beforeHandle: requireAuth },
(app) => app
.get('/dashboard', () => 'protected')
.get('/settings', () => 'protected')
)
// Any route outside the guard is NOT protected
.get('/', () => 'public')#Request Lifecycle
Every request passes through Elysia's lifecycle in order. Understanding where each hook runs helps you pick the right one for the job.
Elysia Middleware Chain
GET /api/users.use(cors()).use(staticPlugin()).guard({ beforeHandle: checkAuth }).derive(() => ({ user: getUser() })).get("/", ({ user }) => ...)// Elysia Request Lifecycle (in order)
// ═══════════════════════════════════
// 1. onRequest : Earliest hook. Minimal context. Always global.
// Best for: rate limiting, analytics, custom headers
// 2. onParse : Body parsing (JSON, form data, etc.)
// 3. onTransform : Mutate context before validation
// └── derive() : Create per-request context values (pre-validation)
// ── Validation ── : Schema validation runs here
// 4. onBeforeHandle : After validation, before handler
// └── resolve() : Like derive() but type-safe (post-validation)
// Best for: auth checks, access control, redirects
// 5. Route Handler : Your actual route logic
// 6. onAfterHandle : Transform or inspect the response
// 7. mapResponse : Convert to Web Standard Response (compression, etc.)
// 8. onError : Catches thrown errors from any phase
// 9. onAfterResponse : After response sent. Best for: cleanup, loggingderive but runs after validation, so types are guaranteed. Preferred for type-safe context values.See the full lifecycle details in the Elysia lifecycle documentation.
#Registration Order Matters
Hooks only apply to routes registered after them. The one exception is onRequest, which is always global.
// IMPORTANT: Hooks only apply to routes registered AFTER them
// (except onRequest, which is always global)
new Elysia()
.onBeforeHandle(() => console.log('auth check'))
.get('/protected', () => 'has auth check') // ✓ hook applies
.onBeforeHandle(() => console.log('logging'))
.get('/both', () => 'has both hooks') // ✓ both hooks apply
.listen(3000);#Auth Guard
Authentication is the most common middleware pattern. AbsoluteJS gives you two approaches: the built-in @absolutejs/auth plugin, or a manual guard pattern using Elysia's guard + resolve.
#Using @absolutejs/auth
The @absolutejs/auth package provides a protectRoute helper that handles session validation, token refresh, and cleanup automatically.
// Using @absolutejs/auth : the built-in solution
import { absoluteAuth } from '@absolutejs/auth';
new Elysia()
.use(absoluteAuth<User>(authConfig))
// protectRoute is available on all routes after .use(absoluteAuth)
.get('/dashboard', ({ protectRoute }) =>
protectRoute(
// Authenticated : render the page
() => handleReactPageRequest({ Page: Dashboard, index: asset(manifest, 'DashboardIndex') }),
// Not authenticated : render fallback
() => handleReactPageRequest({ Page: SignIn, index: asset(manifest, 'SignInIndex') })
)
)#Manual Auth Pattern
For custom auth logic, use guard with a beforeHandle check and resolve to extract typed user context. resolve is preferred over derive here because it runs after validation, giving you type-safe access to headers and body.
// Manual auth pattern using guard + resolve
// resolve runs AFTER validation, so types are guaranteed
new Elysia()
.guard(
{
headers: t.Object({
authorization: t.TemplateLiteral('Bearer ${string}')
}),
beforeHandle({ headers, status }) {
if (!headers.authorization) return status(401, 'Missing token');
}
},
(app) => app
// resolve extracts typed context for all guarded routes
.resolve(({ headers }) => ({
userId: decodeToken(headers.authorization.slice(7))
}))
.get('/profile', ({ userId }) => getUserProfile(userId))
.get('/settings', ({ userId }) => getUserSettings(userId))
)
// Routes outside the guard have no auth requirement
.get('/', () => handleReactPageRequest({ Page: Home, index: asset(manifest, 'HomeIndex') }))#Redirects & URL Rewriting
Use set.redirect in any hook or handler to redirect requests. Return early to skip the route handler.
// Redirects using set.redirect in onBeforeHandle
new Elysia()
.onBeforeHandle(({ set, request }) => {
const url = new URL(request.url);
// Redirect HTTP to HTTPS
if (url.protocol === 'http:') {
set.redirect = `https://${url.host}${url.pathname}`;
return; // early return skips the handler
}
})
// Redirect old URLs to new ones
.get('/old-page', ({ set }) => {
set.redirect = '/new-page';
})
// Auth redirect : send unauthenticated users to login
.get('/dashboard', ({ cookie, set }) => {
if (!cookie.session.value) {
set.redirect = '/login';
return;
}
return handleReactPageRequest({ Page: Dashboard, index: asset(manifest, 'DashboardIndex') });
})#CORS & Custom Headers
For CORS, the @elysiajs/cors plugin is the simplest option. For custom header logic, use onRequest: it runs earliest and is always global, making it ideal for headers that should apply to every response.
// Option 1: Use the @elysiajs/cors plugin (recommended)
import { cors } from '@elysiajs/cors';
new Elysia()
.use(cors()) // sensible defaults
.use(cors({ // or configure it
origin: 'https://myapp.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true
}))
// Option 2: Manual CORS via onRequest (for custom logic)
new Elysia()
.onRequest(({ set, request }) => {
const origin = request.headers.get('origin');
if (origin && allowedOrigins.includes(origin)) {
set.headers['Access-Control-Allow-Origin'] = origin;
set.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE';
set.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization';
set.headers['Access-Control-Allow-Credentials'] = 'true';
}
// Handle preflight
if (request.method === 'OPTIONS') {
set.headers['Access-Control-Max-Age'] = '86400';
return new Response(null, { status: 204 });
}
})#Rate Limiting
Rate limiting belongs in onRequest since it should run before any parsing or validation. Here's a simple in-memory rate limiter built as a reusable plugin:
// Simple rate limiter using onRequest + state
const rateLimiter = new Elysia({ name: 'rate-limiter' })
.state('requests', new Map<string, { count: number; resetAt: number }>())
.onRequest(({ store, request, server, status }) => {
const ip = server?.requestIP(request)?.address ?? 'unknown';
const now = Date.now();
const window = 60_000; // 1 minute
const limit = 100; // requests per window
const record = store.requests.get(ip);
if (!record || now > record.resetAt) {
store.requests.set(ip, { count: 1, resetAt: now + window });
return;
}
record.count++;
if (record.count > limit) {
return status(429, 'Too many requests');
}
})
.as('global');
// Usage
new Elysia()
.use(rateLimiter)
.get('/', () => 'Hello')
.listen(3000);For production, consider a community rate limiting plugin or backing the store with Redis for multi-process deployments.
#Scoping Middleware
By default, Elysia hooks are isolated to the plugin they're defined in. This prevents plugins from accidentally affecting other parts of your application. You can control this with three scope levels:
// Elysia hooks are ISOLATED by default (local scope)
// Three scope levels control where hooks apply:
// local (default) : only the current plugin instance
// scoped : current instance + one parent level
// global : all instances everywhere
// ── Inline scope ──
new Elysia()
.derive({ as: 'scoped' }, ({ headers }) => ({
bearer: headers.authorization?.slice(7) ?? null
}))
// ── Guard scope ──
new Elysia()
.guard({
as: 'global',
beforeHandle({ cookie, status }) {
if (!cookie.session.value) return status(401);
}
})
// ── Plugin instance scope ──
const authPlugin = new Elysia({ name: 'auth' })
.derive(({ headers }) => ({
bearer: headers.authorization?.slice(7) ?? null
}))
.as('scoped'); // lifts ALL hooks to parent
new Elysia()
.use(authPlugin)
.get('/', ({ bearer }) => bearer) // bearer is available hereSee the full scoping details in the Elysia plugin scope documentation.