Skip to main content
Technology & EngineeringApi Frameworks365 lines

Elysia

"Elysia: Bun-native web framework with type-safe routing, TypeBox validation, plugins, Eden treaty client, lifecycle hooks, and Swagger documentation"

Quick Summary18 lines
Elysia is a Bun-native web framework designed for ergonomics and performance. It achieves end-to-end type safety through TypeBox validation schemas that simultaneously validate runtime data and infer TypeScript types — no separate type definitions needed. Every route's input and output types propagate to the Eden treaty client, giving you tRPC-like developer experience with standard HTTP semantics.

## Key Points

- Use TypeBox (`t.Object`, `t.String`, etc.) for all validation — it provides runtime validation and compile-time type inference simultaneously.
- Structure the application with plugins: one plugin per domain (auth, users, posts) composed in the main entry point.
- Export the app type for the Eden treaty client to get full end-to-end type safety without codegen.
- Use `derive` to compute and inject values (current user, permissions) into the handler context.
- Define `response` schemas on routes to enable automatic Swagger documentation and response validation.
- Use `guard` to scope authentication and authorization to groups of routes without repeating middleware.
- Handle errors globally with `onError` and use typed error codes (`VALIDATION`, `NOT_FOUND`) for consistent responses.
- **Not using TypeBox for validation.** Skipping validation loses Elysia's core advantage of automatic type inference from runtime schemas.
- **Putting all routes in a single file.** Use plugins and `group` to separate concerns; a monolithic app file is hard to navigate and test.
- **Running Elysia on Node.js.** Elysia is designed for Bun; running it on Node forfeits performance benefits and may cause compatibility issues.
- **Ignoring the `set` object for status codes.** Returning data without setting `set.status` defaults to 200, even for creation or deletion.
- **Deeply nested plugin dependencies.** Keep the plugin graph shallow; deep nesting creates hard-to-debug type inference chains.
skilldb get api-frameworks-skills/ElysiaFull skill: 365 lines
Paste into your CLAUDE.md or agent config

Elysia

Core Philosophy

Elysia is a Bun-native web framework designed for ergonomics and performance. It achieves end-to-end type safety through TypeBox validation schemas that simultaneously validate runtime data and infer TypeScript types — no separate type definitions needed. Every route's input and output types propagate to the Eden treaty client, giving you tRPC-like developer experience with standard HTTP semantics.

Elysia's architecture is built around a plugin system and lifecycle hooks. Plugins compose functionality (auth, CORS, Swagger) while hooks provide fine-grained control over the request/response pipeline. The framework consistently benchmarks as one of the fastest, leveraging Bun's optimized HTTP server and its own compile-time route optimization.

Setup

Basic Application

// Install: bun add elysia

// src/index.ts
import { Elysia } from "elysia";

const app = new Elysia()
  .get("/", () => "Hello Elysia!")
  .get("/json", () => ({ message: "typed response", timestamp: Date.now() }))
  .listen(3000);

console.log(`Server running at ${app.server?.hostname}:${app.server?.port}`);

Application with Configuration

import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";

const app = new Elysia({ prefix: "/api" })
  .use(cors({
    origin: ["https://example.com"],
    methods: ["GET", "POST", "PUT", "DELETE"],
  }))
  .use(swagger({
    documentation: {
      info: { title: "My API", version: "1.0.0" },
      tags: [
        { name: "Users", description: "User management" },
        { name: "Posts", description: "Post operations" },
      ],
    },
  }))
  .listen(3000);

Key Techniques

Type-Safe Routing with Validation

import { Elysia, t } from "elysia";

const app = new Elysia()
  // Path parameters with validation
  .get("/users/:id", ({ params: { id } }) => {
    return db.user.findUnique({ where: { id } });
  }, {
    params: t.Object({
      id: t.String({ format: "uuid" }),
    }),
  })

  // Query parameters
  .get("/posts", ({ query }) => {
    return db.post.findMany({
      take: query.limit,
      skip: (query.page - 1) * query.limit,
      where: query.published !== undefined ? { published: query.published } : undefined,
      orderBy: { createdAt: query.sort },
    });
  }, {
    query: t.Object({
      page: t.Number({ default: 1, minimum: 1 }),
      limit: t.Number({ default: 20, minimum: 1, maximum: 100 }),
      sort: t.Optional(t.Union([t.Literal("asc"), t.Literal("desc")])),
      published: t.Optional(t.Boolean()),
    }),
  })

  // Request body validation
  .post("/posts", async ({ body, set }) => {
    const post = await db.post.create({ data: body });
    set.status = 201;
    return post;
  }, {
    body: t.Object({
      title: t.String({ minLength: 1, maxLength: 200 }),
      content: t.String({ minLength: 10 }),
      tags: t.Array(t.String(), { default: [], maxItems: 5 }),
      published: t.Boolean({ default: false }),
    }),
  })

  // Response schema for documentation and validation
  .get("/users/:id/profile", ({ params: { id } }) => {
    return getProfile(id);
  }, {
    params: t.Object({ id: t.String() }),
    response: t.Object({
      id: t.String(),
      name: t.String(),
      email: t.String({ format: "email" }),
      bio: t.Optional(t.String()),
      postCount: t.Number(),
    }),
  });

Plugins and Modular Architecture

// src/plugins/auth.ts
import { Elysia, t } from "elysia";
import { jwt } from "@elysiajs/jwt";
import { bearer } from "@elysiajs/bearer";

export const authPlugin = new Elysia({ name: "auth" })
  .use(jwt({ name: "jwt", secret: process.env.JWT_SECRET! }))
  .use(bearer())
  .derive(async ({ jwt, bearer, set }) => {
    if (!bearer) {
      set.status = 401;
      throw new Error("Authentication required");
    }

    const payload = await jwt.verify(bearer);
    if (!payload) {
      set.status = 401;
      throw new Error("Invalid token");
    }

    return {
      user: { id: payload.sub as string, role: payload.role as string },
    };
  });

// src/plugins/database.ts
export const dbPlugin = new Elysia({ name: "database" })
  .decorate("db", prisma)
  .onStop(async () => {
    await prisma.$disconnect();
  });

// src/routes/users.ts
export const userRoutes = new Elysia({ prefix: "/users" })
  .use(dbPlugin)
  .get("/", async ({ db }) => {
    return db.user.findMany({
      select: { id: true, name: true, email: true },
    });
  })
  .use(authPlugin)
  .get("/me", async ({ user, db }) => {
    return db.user.findUnique({ where: { id: user.id } });
  })
  .put("/me", async ({ user, body, db }) => {
    return db.user.update({ where: { id: user.id }, data: body });
  }, {
    body: t.Object({
      name: t.Optional(t.String({ minLength: 1 })),
      bio: t.Optional(t.String({ maxLength: 500 })),
    }),
  });

// src/index.ts — Compose everything
const app = new Elysia()
  .use(userRoutes)
  .use(postRoutes)
  .listen(3000);

Lifecycle Hooks

const app = new Elysia()
  // Runs before route matching — use for logging, rate limiting
  .onRequest(({ request }) => {
    console.log(`${request.method} ${new URL(request.url).pathname}`);
  })

  // Runs before handler — use for auth, validation preprocessing
  .onBeforeHandle(({ set, request }) => {
    const apiKey = request.headers.get("x-api-key");
    if (apiKey && !isValidApiKey(apiKey)) {
      set.status = 403;
      return { error: "Invalid API key" };
    }
  })

  // Runs after handler — use for response transformation
  .onAfterHandle(({ response, set }) => {
    if (typeof response === "object" && response !== null) {
      return { data: response, timestamp: new Date().toISOString() };
    }
  })

  // Error handler
  .onError(({ code, error, set }) => {
    switch (code) {
      case "VALIDATION":
        set.status = 400;
        return { error: "Validation failed", details: error.message };
      case "NOT_FOUND":
        set.status = 404;
        return { error: "Route not found" };
      default:
        console.error("Unhandled error:", error);
        set.status = 500;
        return { error: "Internal server error" };
    }
  });

Eden Treaty Client

// Install: bun add @elysiajs/eden

// server.ts — Export the app type
const app = new Elysia()
  .get("/users", () => db.user.findMany(), {
    response: t.Array(t.Object({
      id: t.String(),
      name: t.String(),
      email: t.String(),
    })),
  })
  .post("/users", ({ body }) => db.user.create({ data: body }), {
    body: t.Object({
      name: t.String(),
      email: t.String({ format: "email" }),
    }),
  })
  .get("/users/:id", ({ params: { id } }) => db.user.findUnique({ where: { id } }), {
    params: t.Object({ id: t.String() }),
  })
  .listen(3000);

export type App = typeof app;

// client.ts — Fully typed HTTP client
import { treaty } from "@elysiajs/eden";
import type { App } from "./server";

const api = treaty<App>("localhost:3000");

// Full autocompletion and type inference
const { data: users, error } = await api.users.get();

const { data: newUser } = await api.users.post({
  name: "Alice",
  email: "alice@example.com",
});

const { data: user } = await api.users({ id: "abc-123" }).get();

// Error handling is typed
if (error) {
  switch (error.status) {
    case 400: console.error("Validation:", error.value); break;
    case 401: console.error("Unauthorized"); break;
  }
}

Guards and Scoped Middleware

const app = new Elysia()
  // Public routes
  .get("/health", () => ({ status: "ok" }))
  .get("/posts", () => db.post.findMany({ where: { published: true } }))

  // Guard scopes all subsequent routes with auth requirement
  .guard(
    {
      beforeHandle: async ({ bearer, jwt, set }) => {
        const payload = await jwt.verify(bearer);
        if (!payload) {
          set.status = 401;
          return { error: "Unauthorized" };
        }
      },
    },
    (app) =>
      app
        .get("/me", ({ user }) => user)
        .post("/posts", ({ body, user }) =>
          db.post.create({ data: { ...body, authorId: user.id } })
        )
        .delete("/posts/:id", async ({ params: { id }, user, set }) => {
          const post = await db.post.findUnique({ where: { id } });
          if (post?.authorId !== user.id) {
            set.status = 403;
            return { error: "Forbidden" };
          }
          await db.post.delete({ where: { id } });
          set.status = 204;
        })
  );

WebSocket Support

const app = new Elysia()
  .ws("/chat/:room", {
    body: t.Object({
      message: t.String({ minLength: 1 }),
    }),
    params: t.Object({
      room: t.String(),
    }),
    open(ws) {
      ws.subscribe(`room:${ws.data.params.room}`);
      ws.publish(`room:${ws.data.params.room}`, {
        system: true,
        message: "User joined",
      });
    },
    message(ws, { message }) {
      ws.publish(`room:${ws.data.params.room}`, {
        author: ws.id,
        message,
        timestamp: Date.now(),
      });
    },
    close(ws) {
      ws.publish(`room:${ws.data.params.room}`, {
        system: true,
        message: "User left",
      });
    },
  });

Best Practices

  • Use TypeBox (t.Object, t.String, etc.) for all validation — it provides runtime validation and compile-time type inference simultaneously.
  • Structure the application with plugins: one plugin per domain (auth, users, posts) composed in the main entry point.
  • Export the app type for the Eden treaty client to get full end-to-end type safety without codegen.
  • Use derive to compute and inject values (current user, permissions) into the handler context.
  • Define response schemas on routes to enable automatic Swagger documentation and response validation.
  • Use guard to scope authentication and authorization to groups of routes without repeating middleware.
  • Handle errors globally with onError and use typed error codes (VALIDATION, NOT_FOUND) for consistent responses.

Anti-Patterns

  • Not using TypeBox for validation. Skipping validation loses Elysia's core advantage of automatic type inference from runtime schemas.
  • Putting all routes in a single file. Use plugins and group to separate concerns; a monolithic app file is hard to navigate and test.
  • Running Elysia on Node.js. Elysia is designed for Bun; running it on Node forfeits performance benefits and may cause compatibility issues.
  • Ignoring the set object for status codes. Returning data without setting set.status defaults to 200, even for creation or deletion.
  • Deeply nested plugin dependencies. Keep the plugin graph shallow; deep nesting creates hard-to-debug type inference chains.
  • Using derive for expensive operations without caching. Derived values run on every request in scope; cache database lookups or token verifications when appropriate.

Install this skill directly: skilldb add api-frameworks-skills

Get CLI access →