Lifecycle hooks allow you to intercept and modify requests at different stages of processing. They enable powerful patterns like authentication, logging, data transformation, and error handling.
Lifecycle flow
Every request goes through these stages in order:
onRequest
Executed when a new request is received, before any other processing:
import { Elysia } from 'elysia'
const app = new Elysia ()
. onRequest (({ request , set }) => {
console . log ( ` ${ request . method } ${ request . url } ` )
// Add CORS headers
set . headers [ 'Access-Control-Allow-Origin' ] = '*'
})
. get ( '/' , () => 'Hello' )
Early return
Return a value to skip the route handler:
app
. onRequest (({ headers , set }) => {
if ( ! headers . authorization ) {
set . status = 401
return { error: 'Unauthorized' }
}
})
. get ( '/protected' , () => {
return { data: 'Secret data' }
})
Async execution
app . onRequest ( async ({ set }) => {
await logRequest ()
set . headers [ 'X-Request-ID' ] = generateId ()
})
onParse
Handle custom body parsing for specific content types:
app . onParse (({ request , contentType }) => {
if ( contentType === 'application/custom' ) {
return request . text ()
}
})
Custom parser
Register named parsers for reuse:
app
. parser ( 'xml' , ({ request }) => {
return parseXML ( await request . text ())
})
. post ( '/xml' , ({ body }) => body , {
type: 'application/xml'
})
If a parser returns a truthy value, it becomes the request body. Otherwise, Elysia tries the next parser.
Transform or coerce request data before validation:
import { Elysia , t } from 'elysia'
const app = new Elysia ()
. onTransform (({ params }) => {
// Convert string to number
if ( params . id ) {
params . id = + params . id
}
})
. get ( '/users/:id' , ({ params }) => {
// params.id is now a number
return { id: params . id }
})
Apply transformation to specific routes:
app . get ( '/id/:id' , ({ params }) => params . id , {
transform : ({ params }) => {
params . id = + params . id
},
params: t . Object ({
id: t . Number ()
})
})
derive
Add derived properties to the context:
const app = new Elysia ()
. state ( 'counter' , 0 )
. derive (({ store }) => ({
increment () {
store . counter ++
},
decrement () {
store . counter --
}
}))
. get ( '/count' , ({ store , increment }) => {
increment ()
return { count: store . counter }
})
Derive from request
app
. derive (({ headers }) => ({
auth: headers . authorization ?. split ( ' ' )[ 1 ]
}))
. get ( '/profile' , ({ auth }) => {
if ( ! auth ) return { error: 'Unauthorized' }
return { token: auth }
})
resolve
Create lazy-evaluated properties, executed before the route handler:
const app = new Elysia ()
. resolve (({ headers }) => ({
user : async () => {
const token = headers . authorization
return await getUserFromToken ( token )
}
}))
. get ( '/profile' , async ({ user }) => {
return await user ()
})
resolve runs in the same stack as onBeforeHandle, after validation and transformation.
onBeforeHandle
Execute logic after validation but before the route handler:
app
. onBeforeHandle (({ headers , set }) => {
if ( ! headers . authorization ) {
set . status = 401
return { error: 'Unauthorized' }
}
})
. get ( '/protected' , () => {
return { data: 'Protected data' }
})
Early return
If a value is returned, it becomes the response and skips the route handler:
app
. onBeforeHandle (({ headers , set }) => {
const cached = cache . get ( headers [ 'cache-key' ])
if ( cached ) {
set . headers [ 'X-Cache' ] = 'HIT'
return cached
}
})
. get ( '/data' , () => {
const data = expensiveOperation ()
return data
})
onAfterHandle
Transform the response after the route handler:
app
. onAfterHandle (({ response }) => {
// Wrap all responses in a standard format
return {
success: true ,
data: response ,
timestamp: Date . now ()
}
})
. get ( '/users' , () => [{ id: 1 , name: 'Alice' }])
// Returns: { success: true, data: [...], timestamp: 1234567890 }
mapResolve
Replace all resolved properties:
app
. resolve (() => ({ a: 1 , b: 2 }))
. mapResolve (() => ({ c: 3 }))
. get ( '/' , ({ c }) => c )
// Only 'c' is available, 'a' and 'b' are replaced
onMapResponse
Map the response before sending:
app
. onMapResponse (({ response , set }) => {
if ( typeof response === 'object' ) {
set . headers [ 'Content-Type' ] = 'application/json'
return JSON . stringify ( response )
}
return response
})
. get ( '/json' , () => ({ message: 'Hello' }))
onAfterResponse
Execute cleanup or logging after the response is sent:
app
. onAfterResponse (({ request , set }) => {
console . log ( `Response sent: ${ set . status } ` )
console . log ( `Request: ${ request . method } ${ request . url } ` )
})
. get ( '/' , () => 'Hello' )
Modifying the response in onAfterResponse has no effect as the response is already sent.
onError
Handle errors that occur during request processing:
import { Elysia } from 'elysia'
const app = new Elysia ()
. onError (({ code , error , set }) => {
if ( code === 'NOT_FOUND' ) {
set . status = 404
return { error: 'Route not found' }
}
if ( code === 'VALIDATION' ) {
set . status = 400
return { error: 'Invalid input' , details: error }
}
set . status = 500
return { error: 'Internal server error' }
})
. get ( '/error' , () => {
throw new Error ( 'Something went wrong' )
})
Error codes
NOT_FOUND - Route not found
VALIDATION - Schema validation failed
PARSE - Body parsing failed
INTERNAL_SERVER_ERROR - Uncaught error
UNKNOWN - Unknown error
Hook scope
Hooks can be registered at different scopes:
Global scope
app . onRequest ({ as: 'global' }, ({ request }) => {
console . log ( request . method )
})
Applies to all routes, including those in plugins.
Scoped
app . onRequest ({ as: 'scoped' }, ({ request }) => {
console . log ( request . method )
})
Applies to routes in the current instance and child plugins.
Local
app . onRequest (({ request }) => {
console . log ( request . method )
})
Applies only to routes defined in the current instance.
Multiple hooks
Register multiple hooks of the same type:
app
. onRequest (() => {
console . log ( 'First' )
})
. onRequest (() => {
console . log ( 'Second' )
})
. get ( '/' , () => 'Hello' )
// Logs: "First" then "Second"
Or use an array:
app . onRequest ([
() => console . log ( 'First' ),
() => console . log ( 'Second' )
])
Execution order
Hooks execute in this order:
Global hooks
Scoped hooks
Local hooks
Inline route hooks
Within each scope, hooks run in registration order.
onStart
Executed when the server starts:
app
. onStart (({ server }) => {
console . log ( `Server running on ${ server ?. hostname } : ${ server ?. port } ` )
})
. listen ( 3000 )
Best practices
onRequest for authentication and logging
onTransform for data coercion
onBeforeHandle for authorization checks
onAfterHandle for response formatting
onAfterResponse for cleanup and analytics
onError for error handling
Each hook should have a single responsibility. Don’t mix authentication, transformation, and logging in one hook.
Early returns in hooks skip subsequent processing. Use them for caching, authentication, and validation shortcuts.
Always implement onError to provide meaningful error responses to clients.
Next steps
Validation Validate request data with schemas
Error handling Learn more about error handling