DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Build a Production REST API with ElysiaJS 1.4 and Drizzle ORM (2026 Guide)

Building a production-grade REST API in 2026 means choosing tools that are fast, type-safe, and maintainable. The combination of ElysiaJS 1.4 running on Bun with Drizzle ORM gives you exactly that — a stack that delivers over 290,000 requests per second in benchmarks while keeping your TypeScript types consistent from database schema all the way to your frontend client.

In this guide, you'll build a fully functional CRUD API with authentication, request validation, and automatic OpenAPI documentation — everything you need to take a project from zero to production in 2026.

Why ElysiaJS + Drizzle ORM in 2026?

Before we write any code, let's understand why this stack is worth your attention.

ElysiaJS 1.4 (released September 2025) introduced Standard Schema support, meaning you can now use Zod, Valibot, ArkType, or TypeBox for validation — your choice. More importantly, it benchmarks at 293,991 requests/second without validation and 223,924 req/s with validation (tested November 2025 on Intel i7-13700K with Bun 1.3.2). That's 2x faster than Encore's Rust-powered framework in the same benchmark.

Drizzle ORM hits its stride in 2026 as the go-to TypeScript ORM. Unlike Prisma, Drizzle compiles down to raw SQL with zero overhead. Unlike raw SQL, you get full TypeScript inference. The drizzle-typebox package creates a direct bridge to ElysiaJS — your database schema is your validation schema.

The result: when you change a column in Drizzle, TypeScript immediately flags every API handler that needs updating. No runtime surprises. No schema drift.

Prerequisites

  • Bun 1.3+ installed (curl -fsSL https://bun.sh/install | bash)
  • PostgreSQL 16+ running locally or on Supabase/Neon
  • Basic TypeScript knowledge
  • Familiarity with REST API concepts

Project Setup

Create a new Bun project and install dependencies:

mkdir elysia-drizzle-api && cd elysia-drizzle-api
bun init -y
bun add elysia @elysiajs/swagger drizzle-orm drizzle-typebox postgres
bun add -d drizzle-kit @types/bun
Enter fullscreen mode Exit fullscreen mode

Pin @sinclair/typebox to avoid version conflicts between drizzle-typebox and Elysia (a known gotcha in 2026):

# Check which version Elysia expects
grep "@sinclair/typebox" node_modules/elysia/package.json
Enter fullscreen mode Exit fullscreen mode

Add the override in package.json:

{
  "overrides": {
    "@sinclair/typebox": "0.32.4"
  }
}
Enter fullscreen mode Exit fullscreen mode

Set up your environment variables in .env:

DATABASE_URL=postgres://user:password@localhost:5432/elysia_api
JWT_SECRET=your-super-secret-key-change-in-production
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Database Schema with Drizzle ORM

The key insight with this stack: define your schema once, use it everywhere. Create src/database/schema.ts:

import {
  pgTable,
  varchar,
  text,
  timestamp,
  boolean,
  integer,
  serial
} from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'

// Users table
export const users = pgTable('users', {
  id: varchar('id', { length: 128 })
    .$defaultFn(() => createId())
    .primaryKey(),
  username: varchar('username', { length: 50 }).notNull().unique(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  password: varchar('password', { length: 255 }).notNull(),
  salt: varchar('salt', { length: 64 }).notNull(),
  isActive: boolean('is_active').default(true).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull()
})

// Posts table
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: text('content').notNull(),
  slug: varchar('slug', { length: 300 }).notNull().unique(),
  authorId: varchar('author_id', { length: 128 })
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  published: boolean('published').default(false).notNull(),
  viewCount: integer('view_count').default(0).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull()
})

export const table = {
  users,
  posts
} as const

export type Table = typeof table
Enter fullscreen mode Exit fullscreen mode

Create drizzle.config.ts for migrations:

import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './src/database/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!
  }
})
Enter fullscreen mode Exit fullscreen mode

Generate and run your first migration:

bunx drizzle-kit generate
bunx drizzle-kit migrate
Enter fullscreen mode Exit fullscreen mode

Database Connection

Create src/database/index.ts:

import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'

const queryClient = postgres(process.env.DATABASE_URL!, {
  max: 20,           // Connection pool size
  idle_timeout: 30,  // Close idle connections after 30s
  connect_timeout: 10
})

export const db = drizzle(queryClient, { schema })
export type DB = typeof db
Enter fullscreen mode Exit fullscreen mode

Converting Drizzle Schema to Elysia Validators

This is where the magic happens. Create src/validators/index.ts:

import { t } from 'elysia'
import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'
import { table } from '../database/schema'

// ---- User validators ----

const _insertUser = createInsertSchema(table.users, {
  email: t.String({ format: 'email' }),
  username: t.String({ minLength: 3, maxLength: 50, pattern: '^[a-zA-Z0-9_]+$' }),
  password: t.String({ minLength: 8, maxLength: 100 })
})

// Expose only fields safe for registration
export const CreateUserBody = t.Omit(_insertUser, ['id', 'salt', 'isActive', 'createdAt', 'updatedAt'])

// Login body — just email + password
export const LoginBody = t.Object({
  email: t.String({ format: 'email' }),
  password: t.String({ minLength: 8 })
})

// Safe user shape for responses (no password, no salt)
export const UserResponse = t.Omit(
  createSelectSchema(table.users),
  ['password', 'salt']
)

// ---- Post validators ----

const _insertPost = createInsertSchema(table.posts, {
  title: t.String({ minLength: 1, maxLength: 255 }),
  content: t.String({ minLength: 10 }),
  slug: t.String({ pattern: '^[a-z0-9-]+$' })
})

export const CreatePostBody = t.Omit(_insertPost, ['id', 'authorId', 'viewCount', 'createdAt', 'updatedAt'])

export const UpdatePostBody = t.Partial(
  t.Omit(_insertPost, ['id', 'authorId', 'viewCount', 'createdAt', 'updatedAt'])
)

export const PostIdParam = t.Object({
  id: t.Numeric({ minimum: 1 })
})

export const PaginationQuery = t.Object({
  page: t.Optional(t.Numeric({ minimum: 1, default: 1 })),
  limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100, default: 20 }))
})
Enter fullscreen mode Exit fullscreen mode

Because createInsertSchema reads your Drizzle table definition at compile time, if you add a NOT NULL column to your schema, TypeScript will immediately error on any validator that omits it. Schema drift becomes impossible.

Building the ElysiaJS Application

Create src/index.ts:

import { Elysia } from 'elysia'
import { swagger } from '@elysiajs/swagger'
import { authRoutes } from './routes/auth'
import { postRoutes } from './routes/posts'
import { db } from './database'

const app = new Elysia()
  .use(
    swagger({
      documentation: {
        info: {
          title: 'ElysiaJS + Drizzle API',
          version: '1.0.0',
          description: 'Production-ready REST API built with ElysiaJS 1.4 and Drizzle ORM'
        },
        tags: [
          { name: 'auth', description: 'Authentication endpoints' },
          { name: 'posts', description: 'Blog post management' }
        ]
      }
    })
  )
  // Inject DB into context for all routes
  .decorate('db', db)
  // Global error handler
  .onError(({ code, error, set }) => {
    if (code === 'VALIDATION') {
      set.status = 422
      return { error: 'Validation failed', details: error.message }
    }
    if (code === 'NOT_FOUND') {
      set.status = 404
      return { error: 'Resource not found' }
    }
    console.error(error)
    set.status = 500
    return { error: 'Internal server error' }
  })
  .use(authRoutes)
  .use(postRoutes)
  .listen(process.env.PORT ?? 3000)

console.log(`🦊 ElysiaJS API running at http://localhost:${app.server?.port}`)

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

Auth Routes with JWT

Create src/routes/auth.ts:

import { Elysia, t } from 'elysia'
import { eq } from 'drizzle-orm'
import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'
import { db } from '../database'
import { users } from '../database/schema'
import { CreateUserBody, LoginBody, UserResponse } from '../validators'

// Simple JWT helper (use @elysiajs/jwt in production for full features)
const signToken = (payload: object): string => {
  const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url')
  const body = Buffer.from(JSON.stringify({ ...payload, iat: Date.now(), exp: Date.now() + 7 * 24 * 60 * 60 * 1000 })).toString('base64url')
  const { createHmac } = require('crypto')
  const sig = createHmac('sha256', process.env.JWT_SECRET!).update(`${header}.${body}`).digest('base64url')
  return `${header}.${body}.${sig}`
}

const hashPassword = (password: string, salt: string): string => {
  return scryptSync(password, salt, 64).toString('hex')
}

export const authRoutes = new Elysia({ prefix: '/auth', tags: ['auth'] })
  .post(
    '/register',
    async ({ body, set }) => {
      // Check for duplicate email/username
      const existing = await db
        .select({ id: users.id })
        .from(users)
        .where(eq(users.email, body.email))
        .limit(1)

      if (existing.length > 0) {
        set.status = 409
        return { error: 'Email already registered' }
      }

      const salt = randomBytes(32).toString('hex')
      const hashedPassword = hashPassword(body.password, salt)

      const [newUser] = await db
        .insert(users)
        .values({
          username: body.username,
          email: body.email,
          password: hashedPassword,
          salt
        })
        .returning({
          id: users.id,
          username: users.username,
          email: users.email,
          isActive: users.isActive,
          createdAt: users.createdAt,
          updatedAt: users.updatedAt
        })

      set.status = 201
      return { user: newUser, token: signToken({ userId: newUser.id }) }
    },
    {
      body: CreateUserBody,
      detail: { summary: 'Register a new user' }
    }
  )
  .post(
    '/login',
    async ({ body, set }) => {
      const [user] = await db
        .select()
        .from(users)
        .where(eq(users.email, body.email))
        .limit(1)

      if (!user) {
        set.status = 401
        return { error: 'Invalid credentials' }
      }

      const hash = hashPassword(body.password, user.salt)
      const isValid = timingSafeEqual(
        Buffer.from(hash),
        Buffer.from(user.password)
      )

      if (!isValid) {
        set.status = 401
        return { error: 'Invalid credentials' }
      }

      return {
        token: signToken({ userId: user.id }),
        user: {
          id: user.id,
          username: user.username,
          email: user.email
        }
      }
    },
    {
      body: LoginBody,
      detail: { summary: 'Login and get JWT token' }
    }
  )
Enter fullscreen mode Exit fullscreen mode

Posts Routes with Full CRUD

Create src/routes/posts.ts:

import { Elysia, t } from 'elysia'
import { eq, desc, count, sql } from 'drizzle-orm'
import { db } from '../database'
import { posts, users } from '../database/schema'
import { CreatePostBody, UpdatePostBody, PostIdParam, PaginationQuery } from '../validators'

export const postRoutes = new Elysia({ prefix: '/posts', tags: ['posts'] })
  // List posts with pagination
  .get(
    '/',
    async ({ query }) => {
      const page = query.page ?? 1
      const limit = query.limit ?? 20
      const offset = (page - 1) * limit

      const [allPosts, [{ total }]] = await Promise.all([
        db
          .select({
            id: posts.id,
            title: posts.title,
            slug: posts.slug,
            published: posts.published,
            viewCount: posts.viewCount,
            createdAt: posts.createdAt,
            author: {
              id: users.id,
              username: users.username
            }
          })
          .from(posts)
          .leftJoin(users, eq(posts.authorId, users.id))
          .where(eq(posts.published, true))
          .orderBy(desc(posts.createdAt))
          .limit(limit)
          .offset(offset),
        db.select({ total: count() }).from(posts).where(eq(posts.published, true))
      ])

      return {
        data: allPosts,
        pagination: {
          page,
          limit,
          total: Number(total),
          pages: Math.ceil(Number(total) / limit)
        }
      }
    },
    {
      query: PaginationQuery,
      detail: { summary: 'List published posts with pagination' }
    }
  )
  // Get single post by ID (and increment view count)
  .get(
    '/:id',
    async ({ params, set }) => {
      const [post] = await db
        .update(posts)
        .set({ viewCount: sql`${posts.viewCount} + 1` })
        .where(eq(posts.id, params.id))
        .returning()

      if (!post) {
        set.status = 404
        return { error: 'Post not found' }
      }

      return { data: post }
    },
    {
      params: PostIdParam,
      detail: { summary: 'Get a post by ID (increments view count)' }
    }
  )
  // Create post
  .post(
    '/',
    async ({ body, set }) => {
      // In production, extract authorId from JWT via middleware
      const authorId = 'demo-author-id'

      const [newPost] = await db
        .insert(posts)
        .values({ ...body, authorId })
        .returning()

      set.status = 201
      return { data: newPost }
    },
    {
      body: CreatePostBody,
      detail: { summary: 'Create a new post' }
    }
  )
  // Update post
  .patch(
    '/:id',
    async ({ params, body, set }) => {
      const [updated] = await db
        .update(posts)
        .set({ ...body, updatedAt: new Date() })
        .where(eq(posts.id, params.id))
        .returning()

      if (!updated) {
        set.status = 404
        return { error: 'Post not found' }
      }

      return { data: updated }
    },
    {
      params: PostIdParam,
      body: UpdatePostBody,
      detail: { summary: 'Update a post' }
    }
  )
  // Delete post
  .delete(
    '/:id',
    async ({ params, set }) => {
      const [deleted] = await db
        .delete(posts)
        .where(eq(posts.id, params.id))
        .returning({ id: posts.id })

      if (!deleted) {
        set.status = 404
        return { error: 'Post not found' }
      }

      set.status = 204
    },
    {
      params: PostIdParam,
      detail: { summary: 'Delete a post' }
    }
  )
Enter fullscreen mode Exit fullscreen mode

End-to-End Type Safety with Eden Treaty

One of ElysiaJS's killer features is Eden Treaty — a client library that infers your entire API's types automatically. Add it to your frontend:

bun add elysia @elysiajs/eden
Enter fullscreen mode Exit fullscreen mode
// frontend/api.ts
import { treaty } from '@elysiajs/eden'
import type { App } from '../api/src/index'

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

// Fully typed — TypeScript knows the response shape
const { data, error } = await api.posts.get({
  query: { page: 1, limit: 10 }
})

// data.data[0].title → TypeScript knows this exists
// data.pagination.total → TypeScript knows this too
Enter fullscreen mode Exit fullscreen mode

No code generation step. No GraphQL schema. Just TypeScript inference across your entire stack.

Running in Production

Add these scripts to package.json:

{
  "scripts": {
    "dev": "bun run --watch src/index.ts",
    "build": "bun build src/index.ts --target bun --outdir dist",
    "start": "bun dist/index.js",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}
Enter fullscreen mode Exit fullscreen mode

For production, compile to a single binary (this is what gets you to 290k+ req/s):

bun build src/index.ts --compile --outfile server
./server
Enter fullscreen mode Exit fullscreen mode

The --compile flag bundles your entire app into a single executable that doesn't need Bun installed on the target machine. This is what the ElysiaJS benchmark uses — it's not just a raw framework number, it's your realistic production performance.

Performance Benchmarks: What to Expect

Based on the November 2025 benchmarks on Intel i7-13700K:

Scenario Req/s
ElysiaJS (no validation) 293,991
ElysiaJS (with validation) 223,924
Encore (Rust-powered, no validation) 139,033
Encore (with validation) 95,854

ElysiaJS's JIT compiler pre-computes validation logic at startup — when a request arrives, most of the type-checking work is already done. That's why adding validation barely impacts throughput.

For comparison: a standard Express.js app on Node.js typically handles 15,000–25,000 req/s for similar workloads. ElysiaJS + Bun is roughly 10–15x faster.

Production Checklist

Before going live with this stack, verify:

  • [ ] Rate limiting — add @elysiajs/rate-limit for per-IP throttling
  • [ ] JWT middleware — use @elysiajs/jwt for production-grade token handling
  • [ ] CORS — configure @elysiajs/cors for your allowed origins
  • [ ] Helmet-equivalent headers — set security headers in a global middleware
  • [ ] Migration CI step — run drizzle-kit migrate in your deployment pipeline before app start
  • [ ] Connection pool tuning — adjust max connections based on your Postgres plan
  • [ ] Health check endpoint — add GET /health that pings the database
  • [ ] Structured logging — ElysiaJS supports Pino via the lifecycle hooks

Where to Go Next

This API stack pairs naturally with 1xAPI if you're building monetized APIs — the same pattern works for any data-driven API product you want to expose on RapidAPI or sell directly.

For the OpenAPI docs (available at http://localhost:3000/swagger), the @elysiajs/swagger plugin auto-generates from your TypeBox validators — no manual documentation needed.

Drizzle Studio (bun run db:studio) gives you a web UI to browse your data at https://local.drizzle.studio — handy during development.

Conclusion

The ElysiaJS 1.4 + Drizzle ORM stack solves the three biggest pain points in TypeScript API development:

  1. Performance — Bun's runtime + ElysiaJS's JIT compiler gets you 200k+ req/s without exotic infrastructure
  2. Type safety — Your database schema drives your API validators via drizzle-typebox, making schema drift a compile-time error instead of a production incident
  3. Developer experience — Eden Treaty brings those types to your frontend without code generation or a separate schema format

In 2026, there's no reason to accept runtime type errors between your database, API, and frontend. This stack eliminates them at every layer.

Top comments (0)