Plugin
A plugin is a part that is decoupled from the main instance. Every Elysia instance can run independently or be used as part of another instance.
Let’s put a small app inside another one.
Here, the plugin decorates a plugin property, and both the plugin and the main app read it. Choose either route to see its response.
import { Elysia } from 'elysia'
const plugin = new Elysia() .decorate('plugin', 'hi') .get('/plugin', ({ plugin }) => plugin)
const app = new Elysia() .use(plugin) .get('/', ({ plugin }) => plugin) .listen(3000)hi
Dependency
Section titled “Dependency”Elysia is composed of multiple mini Elysia apps that can run independently, much like microservices that communicate with each other.
When an instance needs another instance’s service, you must explicitly declare the dependency.
import { Elysia, t } from 'elysia'
class Auth { getProfile() { return { name: 'Elysia User' } }}
const auth = new Elysia() .decorate({ auth: new Auth() })
const main = new Elysia() .use(auth) .get('/profile', ({ auth }) => auth.getProfile())Without .use(auth), the handler doesn’t know about Auth. Registering it on some other instance doesn’t make it available here.
Here’s the missing-then-available moment in motion. Step through it with the stage buttons.
How dependencies become available
authProvides.use(auth)mainNo dependency/profileAuth missingThis is similar to dependency injection: each instance declares what it needs. Being explicit makes dependencies easier to track and modules easier to reuse.
Deduplication
Section titled “Deduplication”An unnamed plugin’s hooks can be registered more than once when the plugin is shared between instances.
Give the plugin a unique name so Elysia can deduplicate its lifecycle hooks.
import { Elysia } from 'elysia'
const ip = new Elysia({ name: 'ip' }) .derive('global', ({ server, request }) => ({ ip: server?.requestIP(request) })) .get('/ip', ({ ip }) => ip)
const router1 = new Elysia() .use(ip) .get('/ip-1', ({ ip }) => ip)
const router2 = new Elysia() .use(ip) .get('/ip-2', ({ ip }) => ip)
const server = new Elysia() .use(router1) .use(router2)'global' shares the hook with every instance that uses the plugin, directly or through another plugin; see scope. The shared ip hook runs once per request, even though both routers use it. requestIP is available when a supporting server adapter supplies server; otherwise ip is undefined.
Here’s that example in motion. Watch ip join both routers, then get registered once when server uses both routers.
How plugins deduplicate
router1/ip-1.use(router1)router2/ip-2.use(router2)serverNamed hooksderiveGlobal scope
For a configurable plugin, include its options as seed. The combination of name and seed identifies a plugin: equal configurations deduplicate, while different seeds stay distinct.
import { Elysia } from 'elysia'
const version = (value = 1) => new Elysia({ name: 'version', seed: value}) .get('/version', value)Naming a plugin deduplicates registration; it doesn’t stop your plugin factory from running when you call it. Keep work that should happen only once outside repeated factory calls.
Global vs explicit dependency
Section titled “Global vs explicit dependency”Some dependencies make more sense globally. Others should be declared by every instance that uses them.
| Use a global dependency for | Use an explicit dependency for |
|---|---|
| Plugins that don’t add types, such as CORS, compression, or security headers | Plugins that add types, such as macros, state, or models |
| Lifecycle hooks that should apply throughout the app, such as tracing or logging | Business logic that handlers interact with, such as authentication or a database |
Global examples include OpenAPI documentation, OpenTelemetry tracing, and logging. Explicit examples include stores and sessions, ORM or ODM models, authentication and database services, and feature modules such as chat or notifications.
If your dependency doesn’t need to be global, prefer an explicit dependency.
Elysia lifecycle hooks are encapsulated within their own instance. A plugin’s hooks don’t automatically apply to the routes of an instance that uses it.
In the example below, current.use(child) makes child a descendant of current. In the other direction, parent.use(current) makes parent its immediate parent, and main.use(parent) adds another parent above it.
Scope level
Section titled “Scope level”Choose how far a hook reaches with the first argument of onBeforeHandle: local, plugin, or global.
Each step uses the same plugin tree. Only the scope changes. The hook logs hi without returning a value, so every route still returns its handler’s hi response.
Local scope
Section titled “Local scope”local applies to the current instance and its descendants.
- Requests to
/currentand/childloghi. - Requests to
/parentand/maindon’t run the hook.
This is the default scope. You can omit 'local' and write onBeforeHandle(() => { ... }).
import { Elysia } from 'elysia'
const child = new Elysia() .get('/child', 'hi')
const current = new Elysia() .onBeforeHandle('local', () => { console.log('hi') }) .use(child) .get('/current', 'hi')
const parent = new Elysia() .use(current) .get('/parent', 'hi')
const main = new Elysia() .use(parent) .get('/main', 'hi')Plugin scope
Section titled “Plugin scope”plugin also reaches the immediate parent.
- Requests to
/child,/current, and/parentloghi.
The hook stops at parent, so /main doesn’t run it.
import { Elysia } from 'elysia'
const child = new Elysia() .get('/child', 'hi')
const current = new Elysia() .onBeforeHandle('plugin', () => { console.log('hi') }) .use(child) .get('/current', 'hi')
const parent = new Elysia() .use(current) .get('/parent', 'hi')
const main = new Elysia() .use(parent) .get('/main', 'hi')Global scope
Section titled “Global scope”global continues through every parent that uses the plugin. All four routes log hi, including /main.
import { Elysia } from 'elysia'
const child = new Elysia() .get('/child', 'hi')
const current = new Elysia() .onBeforeHandle('global', () => { console.log('hi') }) .use(child) .get('/current', 'hi')
const parent = new Elysia() .use(current) .get('/parent', 'hi')
const main = new Elysia() .use(parent) .get('/main', 'hi')The routes that run the hook in each scope are:
| Scope | /child | /current | /parent | /main |
|---|---|---|---|---|
local | Yes | Yes | No | No |
plugin | Yes | Yes | Yes | No |
global | Yes | Yes | Yes | Yes |
Registration order still matters. A hook applies to routes and plugins registered after it. Sharing a hook through .use() doesn’t apply it to routes registered earlier on the receiving instance.
Use the diagram to trace the same hook through the plugin tree.
How hook scope reaches the tree
Scope level
Overviewmain/mainNot shown yetparent/parentNot shown yetcurrent/currentNot shown yetchild/childNot shown yet
To promote hooks already registered on an instance, see Scope cast for .as('plugin') and .as('global').
Config
Section titled “Config”To make a plugin reusable, let callers configure the parts that need to change. A function can accept options and return an Elysia instance.
import { Elysia } from 'elysia'
const version = (version = 1) => new Elysia() .get('/version', version)
const app = new Elysia() .use(version(1)) .listen(3000)Here /version returns 1. Pass another value to create a differently configured plugin.
Functional callback
Section titled “Functional callback”Prefer a new plugin instance when you can. Functional callbacks receive the main instance directly, which makes encapsulation and scope harder to reason about.
To define one, write a function that accepts Elysia and returns the extended instance.
import { Elysia } from 'elysia'
const plugin = (app: Elysia) => app .state('counter', 0) .get('/plugin', () => 'Hi')
const app = new Elysia() .use(plugin) .get('/counter', ({ store: { counter } }) => counter) .listen(3000)Hi
The callback assigns properties directly to the main instance. /plugin returns Hi, and /counter returns 0.
In Elysia 2, the old app.store instance getter is gone. Use store from the handler’s context to read state.
A guard applies hooks and schemas to multiple routes at once. Put the guarded routes inside its callback.
import { Elysia, t } from 'elysia'import { signUp, signIn, isUserExists } from './auth'
new Elysia() .guard( { body: t.Object({ username: t.String(), password: t.String() }) }, (app) => app .post('/sign-up', ({ body }) => signUp(body)) .post('/sign-in', { beforeHandle: isUserExists }, ({ body }) => signIn(body)) ) .get('/', 'hi') .listen(3000)The body schema applies to /sign-up and /sign-in, but not /. The imported functions contain your application’s account logic.
| Path | Has body validation |
|---|---|
/sign-up | Yes |
/sign-in | Yes |
/ | No |
Guard accepts the same schema and hook options as a route. The difference is that it applies them to all routes in its callback.
The example is equivalent to repeating those options on each route:
import { Elysia, t } from 'elysia'import { signUp, signIn, isUserExists } from './auth'
new Elysia() .post('/sign-up', { body: t.Object({ username: t.String(), password: t.String() }) }, ({ body }) => signUp(body)) .post('/sign-in', { beforeHandle: isUserExists, body: t.Object({ username: t.String(), password: t.String() }) }, ({ body }) => signIn(body)) .get('/', 'hi') .listen(3000)In Elysia 2, a route’s schema replaces the corresponding inherited guard schema by default. Add schema: 'merge' to the guard if both schemas must be satisfied. See validation for more.
Grouped guard
Section titled “Grouped guard”A group can take three arguments: a prefix, guard options, and an app callback.
Start with a nested group and guard:
import { Elysia, t } from 'elysia'
new Elysia() .group('/v1', (app) => app.guard( { body: t.Literal('Rikuhachima Aru') }, (app) => app.post('/student', ({ body }) => body) ) ) .listen(3000)Move the guard options into the group’s second argument to get the same behavior with less nesting:
import { Elysia, t } from 'elysia'
new Elysia() .group( '/v1', { body: t.Literal('Rikuhachima Aru') }, (app) => app.post('/student', ({ body }) => body) ) .listen(3000)Both versions register POST /v1/student. A JSON string body of "Rikuhachima Aru" passes validation and is returned by the handler.
Scope cast
Section titled “Scope cast”To apply hooks to a parent, choose how much you want to share:
Inline
Define an inline scope for a single lifecycle hook.Guard
Use guard scope for a set of hooks and schemas.Instance
Use instance scope to promote its existing hooks and schemas.
Inline as
Section titled “Inline as”Pass a scope string before the callback to share one lifecycle hook.
import { Elysia } from 'elysia'
const plugin = new Elysia() .derive('plugin', () => ({ hi: 'ok' })) .get('/child', ({ hi }) => hi)
const main = new Elysia() .use(plugin) .get('/parent', ({ hi }) => hi)The derived hi property is available to both routes. This form changes only this hook’s scope.
Guard as
Section titled “Guard as”Pass a scope string before the guard options to share multiple hooks and schemas together.
import { Elysia, t } from 'elysia'
const plugin = new Elysia() .guard('plugin', { response: t.String(), beforeHandle() { console.log('ok') } }) .get('/child', 'ok')
const main = new Elysia() .use(plugin) .get('/parent', 'hello')The response schema and hook apply to both routes. For a typed derived property, use .derive('plugin', callback) instead of putting it in this guard.
Instance as
Section titled “Instance as”.as() promotes the hooks and schema scopes already registered on the current instance.
import { Elysia } from 'elysia'
const plugin = new Elysia() .derive(() => ({ hi: 'ok' })) .get('/child', ({ hi }) => hi) .as('plugin')
const main = new Elysia() .use(plugin) .get('/parent', ({ hi }) => hi)The plugin scope reaches only one parent. To carry it another level, promote the parent instance as well.
import { Elysia, t } from 'elysia'
const plugin = new Elysia() .guard({ response: t.String() }) .onBeforeHandle(() => { console.log('called') }) .get('/child', 'ok') .as('plugin')
const instance = new Elysia() .use(plugin) .get('/parent', 'still ok') .as('plugin')
const parent = new Elysia() .use(instance) .get('/grandparent', 'also ok')Now the string response schema reaches /grandparent. Returning a number from any of these routes violates the schema; TypeScript will point it out, and response validation checks it at runtime.
Lazy load
Section titled “Lazy load”Modules are eagerly loaded by default, so synchronous plugins are registered before the server starts. A computationally heavy or blocking module can delay startup.
An asynchronous plugin registers its routes once its promise settles. .listen() and app.fetch wait for every pending module before serving requests; only app.handle() can run earlier.
Deferred module
Section titled “Deferred module”A deferred module is an async plugin. This example waits for your file list before registering its routes:
import { Elysia, file } from 'elysia'import { loadAllFiles } from './files'
export const loadStatic = async (app: Elysia) => { const files = await loadAllFiles()
for (const asset of files) { app.get(asset, file(asset)) }
return app}Here, loadAllFiles returns an array of local asset paths that are also suitable route paths. In your main file:
import { Elysia } from 'elysia'import { loadStatic } from './plugin'
const app = new Elysia() .use(loadStatic) .listen(3000)Lazy load module
Section titled “Lazy load module”A dynamic import() can load a synchronous or asynchronous plugin lazily. Export the plugin as the module’s default export:
import { Elysia } from 'elysia'
export default new Elysia() .get('/plugin', 'Hi')import { Elysia } from 'elysia'
const app = new Elysia() .use(import('./plugin')) .listen(3000)This is useful when loading the module itself is expensive. .listen() waits for app.modules on its own, so the server never starts with a route missing. Await app.modules yourself only when you call app.handle() directly, as in tests.
Testing
Section titled “Testing”In tests, await app.modules before requesting a deferred or lazy-loaded route.
import { describe, expect, it } from 'bun:test'import { Elysia } from 'elysia'
describe('Modules', () => { it('inline async', async () => { const app = new Elysia() .use(async (app) => app.get('/async', 'async'))
await app.modules
const response = await app.handle( new Request('http://localhost/async') )
expect(await response.text()).toBe('async') })})