AbsoluteJS

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.

TS
// 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

1
Request
HTTP request arrives at your Elysia server
GET /api/users
2
Plugins
cors(), static(), swagger() : extend server capabilities
.use(cors()).use(staticPlugin())
3
Guards
Authentication, rate limiting, validation : protect routes
.guard({ beforeHandle: checkAuth })
4
Derive
Inject user, db connection, utilities : available in handlers
.derive(() => ({ user: getUser() }))
5
Handler
Your route logic executes with full type safety
.get("/", ({ user }) => ...)
Types Flow
Types flow down the chain in the same order: plugins add methods, guards add context, and derive adds properties. All derived values are fully typed in your handlers.
TS
// 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, logging
1
onRequest
Runs first on every request. Minimal context. Best for rate limiting, analytics, and custom headers.
2
onBeforeHandle
Runs after validation. Return a value to skip the handler. This is your primary auth/access control hook.
3
resolve
Like derive but runs after validation, so types are guaranteed. Preferred for type-safe context values.
4
onAfterHandle
Inspect or transform the response after the handler runs.
5
onAfterResponse
Runs after the response is sent. Use for cleanup and logging.

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.

TS
// 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.

TS
// 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.

TS
// 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.

TS
// 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.

TS
// 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:

TS
// 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:

localdefaultHooks stay in the current plugin instance and its descendants.
scopedHooks propagate one level up to the parent instance.
globalHooks apply to all instances everywhere.
TS
// 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 here

See the full scoping details in the Elysia plugin scope documentation.