Elysia on Bun: Type-Safe Routes, Validation, Plugins and End-to-End Types with Eden
Key takeaways
Elysia is a TypeScript-first framework for Bun where the validation schema also becomes the handler's types, and Eden Treaty carries those types to the client without a codegen step. This post builds an API with routing, hooks, plugins, JWT and WebSocket, then compares Elysia with Hono and Fastify, including the cost of leaning on Bun-specific APIs.
Why Elysia?
Elysia is a TypeScript-first web framework built for Bun:
- Speed: Near the top of Bun HTTP benchmarks, typically ahead of Express and Fastify on Node
- Type safety: TypeBox schemas that validate requests at runtime and type the handler at compile time
- Eden Treaty: Generate type-safe frontend client from your API definition
- Batteries included: Swagger, CORS, JWT, WebSocket — all plugins
Elysia’s core bet is that Bun’s JavaScript runtime (JavaScriptCore, the same engine as Safari) combined with a native HTTP server gives it more headroom than frameworks built to run identically well on Node.js — Elysia leans into Bun-specific APIs rather than staying runtime-agnostic, which is exactly the tradeoff that makes it faster on Bun and less compelling anywhere else. If your deployment target is locked to Node.js (many managed platforms still are), that speed advantage mostly evaporates and Hono or Fastify become the more honest comparison.
It is also worth being precise about what the benchmark numbers measure. Framework benchmarks usually time a “hello world” or a tiny JSON response, where routing and serialization are the whole workload. In a real API, a single database round trip takes far longer than the framework overhead, so the difference between Elysia and Fastify rarely decides your p99 latency. Where Elysia’s design genuinely pays off day to day is the type flow: one schema definition gives you validation, handler types, OpenAPI docs and a typed client. That is the reason to choose it; raw requests per second is a secondary argument.
Setup
# Create project (Bun required)
bun create elysia my-api
cd my-api
bun install
bun run dev
Or from scratch:
mkdir my-api && cd my-api
bun init
bun add elysia
Basic Routing
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/', () => 'Hello Elysia!')
.get('/hello/:name', ({ params }) => `Hello, ${params.name}!`)
.post('/echo', ({ body }) => body)
.listen(3000)
console.log(`Server running at http://localhost:${app.server?.port}`)
The chained style is not just a stylistic preference. Elysia builds up the app’s type one method call at a time: each .get() or .use() returns a new type that includes the routes and context added so far. If you write const app = new Elysia() and then call app.get(...) as separate statements, the runtime behavior is the same, but the type stored in app never learns about those routes, so later features that depend on it (context from .derive(), Eden Treaty on the client) silently lose type information. When types “stop working” in an Elysia project, a broken chain is the first thing to check.
Handlers can return a string, an object (serialized as JSON with the right Content-Type), a Response, or a Bun.file(), and Elysia picks the response type accordingly. .listen() starts Bun’s native server; the app.server property is only set after that call, which is why the log line uses optional chaining.
Type-Safe Validation
Elysia uses TypeBox (JSON Schema) for request/response validation — typically faster than Zod at runtime, because Elysia uses TypeBox’s compiler to generate a plain JavaScript validation function for each schema once, when the route is registered, rather than interpreting a schema object on every request the way classic Zod validators do. For a single request that difference is invisible; at the throughput Elysia is aimed at, avoiding that per-request schema-walking overhead is a meaningful chunk of the benchmark gap against Express and Fastify’s default setup.
import { Elysia, t } from 'elysia'
const app = new Elysia()
// GET with query params validation
.get('/users', ({ query }) => {
return { page: query.page, limit: query.limit }
}, {
query: t.Object({
page: t.Number({ minimum: 1, default: 1 }),
limit: t.Number({ minimum: 1, maximum: 100, default: 20 }),
search: t.Optional(t.String()),
}),
})
// POST with body validation
.post('/users', async ({ body }) => {
const user = await createUser(body)
return user
}, {
body: t.Object({
name: t.String({ minLength: 2, maxLength: 100 }),
email: t.String({ format: 'email' }),
role: t.Union([t.Literal('admin'), t.Literal('user')], {
default: 'user',
}),
}),
// Response type validation + docs generation
response: t.Object({
id: t.Number(),
name: t.String(),
email: t.String(),
createdAt: t.String(),
}),
})
.listen(3000)
Two details in the query schema are easy to trip over. Query strings are always text on the wire, so ?page=2 arrives as the string "2"; Elysia coerces it to a number because the schema says t.Number, and a value like ?page=abc becomes a 400 validation error rather than NaN in your handler. The default values are applied when the parameter is missing, which is why query.page is typed as number rather than number | undefined. If you forget the default and do not wrap the field in t.Optional, a request without that parameter fails validation, which surprises people who expected optional query parameters by default.
The response schema is doing double duty here that’s easy to miss on a first read: it’s not just documentation, it’s an actual runtime check on what your handler returns, and it drives the Swagger schema generation covered later in this guide. That means a handler that accidentally returns an extra internal field, or forgets one the schema promises, fails validation at the framework level instead of silently leaking a field to the client or breaking a frontend that expected it. I’ve found this catches a specific class of bug that’s easy to introduce during refactors — renaming a database column and forgetting to update the handler’s return shape — well before it reaches a code reviewer or, worse, production.
Middleware and Lifecycle Hooks
import { Elysia } from 'elysia'
const app = new Elysia()
// Global hooks
.onRequest(({ request }) => {
console.log(`${request.method} ${request.url}`)
})
.onBeforeHandle(({ request, set }) => {
// Runs before route handler
const token = request.headers.get('Authorization')
if (!token) {
set.status = 401
return { error: 'Unauthorized' }
}
})
.onAfterHandle(({ response, set }) => {
// Runs after route handler
set.headers['X-Powered-By'] = 'Elysia'
})
.onError(({ error, code, set }) => {
if (code === 'NOT_FOUND') {
set.status = 404
return { error: 'Route not found' }
}
if (code === 'VALIDATION') {
set.status = 400
return { error: 'Validation failed', details: error.message }
}
set.status = 500
return { error: 'Internal server error' }
})
.get('/', () => 'Hello!')
.listen(3000)
The four hooks here map to a specific point in the request lifecycle, and picking the right one matters for correctness, not just organization. onRequest fires before routing has even happened, so it’s the place for logging that should capture every request regardless of whether a matching route exists. onBeforeHandle runs after routing but before your handler, which is exactly where auth checks belong — returning early here (as the 401 case does) skips the handler entirely, whereas trying to do the same check inside every individual handler means remembering to repeat it everywhere. onAfterHandle can inspect and modify the response your handler already produced, which is the natural place for response headers or logging the outcome. onError is the catch-all — centralizing error-to-status-code mapping here means individual handlers can just throw and never need their own try/catch blocks for expected error cases.
Hooks apply only to routes registered after them in the chain. Because the onBeforeHandle above is registered before .get('/'), even the root route now demands an Authorization header, which is rarely what you want for a health check. Register public routes first, or scope the auth hook to a group, as the .guard() example further down does.
The more confusing rule concerns plugins. Since Elysia 1.0, a hook registered inside a plugin instance is local to that instance by default: an auth onBeforeHandle defined in an authPlugin protects the plugin’s own routes, but not routes of the parent app that .use()s it. To make it apply to the parent you must declare the scope explicitly, for example onBeforeHandle({ as: 'scoped' }, ...) or { as: 'global' }. I have seen this catch people migrating from Express, where app.use(middleware) always applies to everything registered later; in Elysia the same-looking code quietly leaves the parent’s routes unprotected, and nothing fails until someone tests an endpoint without a token.
Plugins — Modular Architecture
import { Elysia } from 'elysia'
// Define a plugin
const userPlugin = new Elysia({ prefix: '/users' })
.get('/', async () => {
return await db.users.findAll()
})
.get('/:id', async ({ params, set }) => {
const user = await db.users.findById(params.id)
if (!user) { set.status = 404; return { error: 'Not found' } }
return user
})
.post('/', async ({ body }) => {
return await db.users.create(body)
})
const postPlugin = new Elysia({ prefix: '/posts' })
.get('/', () => db.posts.findAll())
// Compose plugins
const app = new Elysia()
.use(userPlugin)
.use(postPlugin)
.listen(3000)
// Routes: GET /users, GET /users/:id, POST /users, GET /posts
The { prefix: '/users' } option is what makes this composable rather than just a way to split files — userPlugin doesn’t need to know it’ll be mounted under /users; it just declares routes relative to its own root, and .use() handles the prefixing when composing into the parent app. That’s the difference between “modular” and merely “split into multiple files”: you can take userPlugin and mount it at a different prefix, or in a different app entirely, without touching its internals. This is the same shape Express’s Router or Fastify’s plugin system uses, and for the same reason — large APIs need route groups that can be developed, tested, and reasoned about independently of where they end up mounted.
When the same plugin is .use()d from several places (a shared dbPlugin pulled in by both userPlugin and postPlugin, for instance), give it a name in its constructor: new Elysia({ name: 'db' }). Elysia uses the name to deduplicate, so the plugin’s decorators and hooks are registered once instead of once per .use(). Without a name, a logging hook inside a shared plugin runs twice per request, which is usually how people discover this rule.
Built-in plugins
bun add @elysiajs/cors @elysiajs/jwt @elysiajs/swagger @elysiajs/bearer
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { jwt } from '@elysiajs/jwt'
import { swagger } from '@elysiajs/swagger'
import { bearer } from '@elysiajs/bearer'
const app = new Elysia()
.use(cors({
origin: ['https://myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}))
.use(swagger({
documentation: {
info: { title: 'My API', version: '1.0.0' },
},
}))
// Auto-generates Swagger UI at /swagger
.use(jwt({
name: 'jwt',
secret: process.env.JWT_SECRET!,
}))
.use(bearer())
// Extracts Bearer token from Authorization header
.listen(3000)
Notice that adding Swagger UI here isn’t a separate documentation-writing task — the swagger() plugin introspects the t.Object() validation schemas you already wrote for request bodies, query params, and responses, and generates the OpenAPI spec from them directly. This is the practical payoff of TypeBox validation being mandatory rather than optional in Elysia’s design: the same schema that rejects a malformed request body at runtime is also the thing that keeps your API docs from drifting out of sync with the actual API, since there’s only one source of truth instead of a schema plus a separately maintained doc comment.
JWT Authentication
import { Elysia, t } from 'elysia'
import { jwt } from '@elysiajs/jwt'
import { bearer } from '@elysiajs/bearer'
const app = new Elysia()
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET! }))
.use(bearer()) // provides `bearer` (the token from the Authorization header)
.post('/auth/login', async ({ body, jwt, set }) => {
const user = await db.users.findByEmail(body.email)
if (!user || !await verifyPassword(body.password, user.passwordHash)) {
set.status = 401
return { error: 'Invalid credentials' }
}
const token = await jwt.sign({
sub: user.id,
role: user.role,
exp: Math.floor(Date.now() / 1000) + 3600,
})
return { token, user: { id: user.id, email: user.email } }
}, {
body: t.Object({
email: t.String({ format: 'email' }),
password: t.String({ minLength: 8 }),
}),
})
// Protected routes
.guard(
{
beforeHandle: async ({ jwt, bearer, set }) => {
const payload = await jwt.verify(bearer)
if (!payload) {
set.status = 401
return { error: 'Unauthorized' }
}
},
},
(app) => app
.get('/api/me', async ({ jwt, bearer }) => {
const payload = await jwt.verify(bearer)
return await db.users.findById(payload!.sub as string)
})
.get('/api/dashboard', () => ({ data: 'protected' }))
)
.listen(3000)
The .guard() call is what makes this scale to more than one or two protected routes — instead of copy-pasting the same beforeHandle auth check into /api/me and /api/dashboard individually (and inevitably forgetting it on a third route someone adds later), .guard() applies the check to every route defined inside its callback. This is the same “protect a whole group at once” idea as the plugin prefix above, applied to auth instead of routing — the failure mode it prevents (a forgotten auth check on one route in a large API) is a real, recurring class of security bug, not a hypothetical one.
Two weaknesses of this example are worth fixing before production. /api/me verifies the token a second time because the payload from beforeHandle is not passed along; the cleaner pattern is a .derive() (or .resolve()) that verifies once and adds user to the context for every guarded handler, typed. And jwt.verify returns false for an invalid or expired token rather than throwing, so the if (!payload) check is essential; forgetting it and reading payload.sub directly produces a TypeError that surfaces as a 500 instead of a 401. The secret should be a long random value, and the ! non-null assertion on process.env.JWT_SECRET only silences TypeScript: if the variable is missing, the plugin receives undefined, so validate the environment at startup instead.
WebSocket
import { Elysia, t } from 'elysia'
const clients = new Set<{ send: Function }>()
const app = new Elysia()
.ws('/ws/chat', {
// Validate incoming messages
body: t.Object({
type: t.Union([t.Literal('message'), t.Literal('join')]),
text: t.Optional(t.String()),
room: t.Optional(t.String()),
}),
open(ws) {
clients.add(ws)
ws.send({ type: 'connected', clientCount: clients.size })
},
message(ws, data) {
if (data.type === 'message') {
// Broadcast to all connected clients
for (const client of clients) {
client.send({ type: 'message', text: data.text, from: ws.id })
}
}
},
close(ws) {
clients.delete(ws)
},
})
.listen(3000)
The body: t.Object(...) schema on a .ws() handler is worth pausing on, because it’s not something most WebSocket libraries give you for free — raw WebSocket messages are just untyped strings or buffers, so validating incoming message shape usually means writing that check by hand in every message handler. Elysia applies the same TypeBox validation pipeline it uses for HTTP request bodies to WebSocket messages, so a malformed message (wrong type, missing text) gets rejected before your message(ws, data) callback ever runs, and data inside the callback is already typed according to the schema — no manual JSON.parse and shape-checking required.
The hand-rolled Set of clients is the simplest broadcast to read, but it is not how I would ship it. Elysia wraps Bun’s ServerWebSocket in its own object, and relying on object identity across the open and close callbacks is fragile; keying a Map by ws.id is more robust. Better still, Bun has native topic-based pub/sub: call ws.subscribe('chat') in open and ws.publish('chat', msg) in message, and Bun fans the message out in native code without a JavaScript loop over every socket. Note that publish sends to every subscriber except the sender, so if the sender should also see its message, send it separately. Whatever you choose, it only works within one process: with two instances behind a load balancer, clients on instance A never see messages from instance B unless you add Redis pub/sub or a similar broker between them.
Eden Treaty — End-to-End Type Safety
Eden Treaty generates a type-safe client from your Elysia API:
bun add elysia @elysiajs/eden
// Server: export the app type
const app = new Elysia()
.get('/users', () => [{ id: 1, name: 'Alice' }])
.post('/users', ({ body }) => ({ // body type is inferred from the schema below
id: 2,
...body,
}), {
body: t.Object({ name: t.String(), email: t.String() }),
})
.listen(3000)
export type App = typeof app
// Client: fully typed
import { treaty } from '@elysiajs/eden'
import type { App } from '../server'
const api = treaty<App>('http://localhost:3000')
// Autocomplete + type checking
const { data: users } = await api.users.get()
// data: { id: number; name: string }[]
const { data: newUser } = await api.users.post({
name: 'Bob',
email: '[email protected]',
})
// newUser: { id: number; name: string; email: string }
// Type error at compile time if wrong:
// await api.users.post({ name: 'Bob' }) // ❌ missing email
Eden Treaty’s trick is exporting the type of the server app (export type App = typeof app), not any runtime code — the client only ever imports a TypeScript type, which is erased at compile time and costs nothing at runtime. That’s what makes it comparable to tRPC rather than to a traditional generated API client (like one produced from an OpenAPI spec by a separate codegen step): there’s no build step to regenerate a client after changing the server, no client/server version skew where the generated client is stale, and no runtime dependency between client and server bundles — just TypeScript’s structural typing doing the work at the type-checking stage. The real constraint this imposes: your frontend and backend need to share a TypeScript project (a monorepo, typically), since the client needs to import type { App } from the actual server source.
Do not annotate handler parameters by hand, as in ({ body }: { body: {...} }): the annotation overrides what Elysia infers from the schema, and if the two drift apart the client sees your annotation, not the validated shape. In practice, Eden problems usually come from version skew rather than from Eden itself. The client and server must resolve the same version of elysia; with two copies in a monorepo, App stops matching and every call degrades to unknown or produces a wall of type errors. Large apps can also make the TypeScript server slow, because the whole route tree is one deeply nested type; splitting the app into plugins and keeping handler return types simple helps. Eden also does not remove the need for runtime validation on the server: the types only protect callers that use the typed client.
Context — Shared State
import { Elysia } from 'elysia'
// Decorate context with shared values
const app = new Elysia()
.decorate('db', new Database())
.decorate('logger', new Logger())
.derive(({ request }) => ({
requestId: crypto.randomUUID(),
ip: request.headers.get('x-forwarded-for') ?? 'unknown',
}))
// db, logger, requestId, ip available in all handlers
.get('/users', ({ db, logger, requestId }) => {
logger.info(`[${requestId}] Fetching users`)
return db.users.findAll()
})
.listen(3000)
.decorate() and .derive() solve related but distinct problems, and mixing them up is a common early mistake. .decorate() adds a fixed value to the context once, at app-setup time — good for things like a database connection or a logger instance that’s the same for every request. .derive() runs on every request and computes something from that specific request’s context, which is why requestId and ip live there instead of in .decorate() — a single shared requestId for every request would defeat the entire point of having one. The type-safety payoff of both: db, logger, requestId, and ip all appear with correct types in every downstream handler’s destructured parameters, with no manual typing of a shared “app context” interface the way you’d need in a plain Express app using res.locals or a custom req extension.
Elysia vs Hono vs Fastify
| Elysia | Hono | Fastify | |
|---|---|---|---|
| Runtime | Bun (+ adapters) | Any | Node.js |
| Speed | Fastest (Bun) | Very fast | Fast |
| Type safety | End-to-end (Eden) | End-to-end (hc RPC client) | Routes only (type providers) |
| Validation | TypeBox (built-in) | Built-in validator + Zod/Valibot middleware | Ajv / JSON Schema (built-in) |
| WebSocket | Built-in | Via adapter | Via plugin |
| Swagger | Plugin | Plugin | Plugin |
| Ecosystem | Growing | Large | Mature |
Hono is the closest competitor in spirit: it also offers typed routes and a typed client (hc), and it runs unchanged on Bun, Node, Deno, Cloudflare Workers and other edge runtimes. If there is any chance your code will move between runtimes, that portability usually outweighs Elysia’s extra speed on Bun. Fastify is the conservative choice for Node: a large plugin ecosystem, years of production use, and JSON-schema validation that also speeds up serialization. Elysia fits best when you have committed to Bun and want the tightest schema-to-client type flow with the least ceremony.
Related Articles
- Hono Framework Guide | Ultra-Fast Edge Web Framework
- Getting Started with Bun
- What TypeScript 5 Changed