Elysia.js + Bun: The Complete Guide (2026)

typescript dev.to

Elysia.js is a Bun-native HTTP framework that handles over 500,000 requests per second in benchmarks. That number is real — it comes from Bun's native HTTP server combined with Elysia's zero-overhead routing. But raw speed isn't the reason to use it. The reason is Eden Treaty: end-to-end type safety between your server and client with zero code generation.

Read the full article with all code examples at stacknotice.com

Setup

bun create elysia my-api
cd my-api
bun run dev
Enter fullscreen mode Exit fullscreen mode

Minimum working server:

// src/index.ts
import { Elysia } from 'elysia'

const app = new Elysia()
  .get('/', () => 'Hello Elysia')
  .listen(3000)

// This export is what enables Eden Treaty
export type App = typeof app
Enter fullscreen mode Exit fullscreen mode

Routing with TypeBox Validation

import { Elysia, t } from 'elysia'

const app = new Elysia()
  .get('/users', () => getUsers())
  .get('/users/:id', ({ params }) => getUserById(params.id))
  .post('/users', ({ body }) => createUser(body), {
    body: t.Object({
      name: t.String({ minLength: 1 }),
      email: t.String({ format: 'email' }),
      role: t.Union([t.Literal('admin'), t.Literal('user')]),
    }),
  })
  .delete('/users/:id', ({ params }) => deleteUser(params.id))
Enter fullscreen mode Exit fullscreen mode

The t namespace is TypeBox — one schema for both runtime validation and TypeScript types.

Route Groups as Plugins

// src/routes/users.ts
export const usersRouter = new Elysia({ prefix: '/users' })
  .get('/', () => UserService.getAll())
  .get('/:id', ({ params }) => UserService.getById(params.id))
  .post('/', ({ body }) => UserService.create(body), {
    body: t.Object({
      name: t.String(),
      email: t.String({ format: 'email' }),
    }),
  })

// src/index.ts
const app = new Elysia()
  .use(usersRouter)
  .use(productsRouter)
  .listen(3000)

export type App = typeof app
Enter fullscreen mode Exit fullscreen mode

Auth Middleware with derive

export const authPlugin = new Elysia({ name: 'auth' })
  .derive({ as: 'scoped' }, async ({ headers, error }) => {
    const token = headers['authorization']?.slice(7)
    if (!token) throw error(401, 'Missing token')

    const payload = await verifyJWT(token)
    if (!payload) throw error(401, 'Invalid token')

    return {
      user: {
        id: payload.sub as string,
        email: payload.email as string,
        role: payload.role as 'admin' | 'user',
      },
    }
  })

// Apply to protected routes
export const protectedRouter = new Elysia({ prefix: '/api' })
  .use(authPlugin)
  .get('/me', ({ user }) => user) // `user` is fully typed
Enter fullscreen mode Exit fullscreen mode

{ as: 'scoped' } means the derived context only applies to this instance — not to parent or sibling routes.

Eden Treaty — The Killer Feature

On your client, import the App type and create a typed client:

// client/api.ts
import { treaty } from '@elysiajs/eden'
import type { App } from '../server/src/index'

export const api = treaty<App>('http://localhost:3000')
Enter fullscreen mode Exit fullscreen mode

Now you have a typed client that mirrors your API structure:

// Fully typed — no codegen, no schema files
const { data: users } = await api.users.get()
const { data: user } = await api.users({ id: '123' }).get()
const { data: newUser } = await api.users.post({
  name: 'Alice',
  email: 'alice@example.com',
})

// TypeScript error — body doesn't match schema
// @ts-expect-error
await api.users.post({ name: 123 }) // must be string
Enter fullscreen mode Exit fullscreen mode

When you change a route's input shape on the server, TypeScript errors appear at every client call site that's now wrong. Zero manual type updates.

Drizzle ORM Integration

// src/plugins/database.ts
import { Elysia } from 'elysia'
import { db } from '../db'

export const databasePlugin = new Elysia({ name: 'database' })
  .decorate('db', db)

// src/routes/posts.ts
export const postsRouter = new Elysia({ prefix: '/posts' })
  .use(databasePlugin)
  .use(authPlugin)
  .get('/', async ({ db }) => {
    return db.select().from(posts).orderBy(desc(posts.publishedAt))
  })
  .post('/', async ({ db, body, user }) => {
    const [post] = await db.insert(posts)
      .values({ ...body, authorId: user.id })
      .returning()
    return post
  }, {
    body: t.Object({
      title: t.String({ minLength: 1 }),
      content: t.String({ minLength: 1 }),
    }),
  })
Enter fullscreen mode Exit fullscreen mode

Common Plugins

bun add @elysiajs/cors @elysiajs/swagger @elysiajs/bearer
Enter fullscreen mode Exit fullscreen mode
const app = new Elysia()
  .use(cors({ origin: ['http://localhost:3000'] }))
  .use(swagger()) // Auto-generated Swagger UI at /swagger
  .use(bearer())  // Extracts Bearer token to context.bearer
Enter fullscreen mode Exit fullscreen mode

The swagger() plugin reads your TypeBox schemas and generates OpenAPI docs automatically.

Elysia vs Alternatives

Use case Best choice
Bun-only, need Eden Treaty Elysia
Multi-runtime (Workers, Deno, Node) Hono
Full Next.js stack Next.js Route Handlers
tRPC-style type safety tRPC + Next.js
Microservices in Bun Elysia

Elysia makes the most sense when you're fully committed to Bun and want end-to-end type safety without tRPC's JSON-RPC overhead. If you need Cloudflare Workers or Deno Deploy, use Hono instead.


For the full guide with lifecycle hooks, global error handling, performance details, and deployment config: stacknotice.com/blog/elysiajs-bun-complete-guide-2026

Source: dev.to

arrow_back Back to Tutorials