Best ElysiaJS + Bun Boilerplates 2026
TL;DR
ElysiaJS doesn't have a rich boilerplate ecosystem yet — the official bun create elysia is the best starting point. The framework is fast (500K req/s on Bun), has unique end-to-end type safety via Eden treaty, and the ecosystem is growing. Most Elysia projects are simple: one entry file, plugins for separation, Drizzle for database. For teams fully committed to Bun: ElysiaJS is compelling. For teams needing Node.js compat: Hono is safer.
Key Takeaways
- Official start:
bun create elysia my-app— generates minimal Elysia app - Eden treaty: type-safe client, no codegen, types from server to client automatically
- Drizzle integration: common choice (Drizzle supports Bun natively)
- Limitation: Bun-only (Node.js support partial, not recommended for production)
- Deployment: Bun runtime required on server (Railway, Fly.io, or custom Docker)
- Performance: ~500K req/s beats Fastify and Hono on Bun
Why ElysiaJS Gained Traction in 2026
ElysiaJS emerged as a credible alternative to Express, Fastify, and Hono in 2025-2026 for a specific reason: it solved type safety differently from everything else in the JavaScript ecosystem. Every other approach to end-to-end type safety requires a contract layer — tRPC defines procedures, OpenAPI generates a spec, GraphQL uses a schema. ElysiaJS routes are the contract. The TypeScript types flow directly from route definitions to the Eden treaty client with no intermediate artifact.
This matters in practice because it eliminates a class of maintenance work. When you change a route handler's return type, your TypeScript client immediately shows type errors at every affected call site. No schema regeneration step, no codegen command, no stale type files to update. For teams that have experienced the pain of tRPC procedure changes or OpenAPI spec drift, this is a meaningful quality-of-life improvement.
The performance story is equally compelling. ElysiaJS on Bun consistently benchmarks at 400K-600K req/s in standard HTTP benchmarks — roughly 2-3x faster than Fastify on Node.js and comparable to Hono on Bun. For API products where throughput matters, this headroom is valuable. The practical implication for most SaaS products is that infrastructure costs drop proportionally: the same request volume needs fewer compute instances.
The caveat that has held back wider adoption is Bun itself. Bun's Node.js compatibility layer is functional but not complete. Libraries that use native Node.js modules (certain crypto operations, some database drivers, native addons compiled for Node) can fail or require workarounds. Teams choosing ElysiaJS in 2026 are betting on Bun's compatibility improving, which it consistently has — but the edge cases still exist.
Official Starter
bun create elysia my-api
cd my-api
# Project structure:
# src/
# index.ts
# .env
# package.json
# tsconfig.json
// src/index.ts — Elysia starter:
import { Elysia } from 'elysia';
const app = new Elysia()
.get('/', () => 'Hello Elysia!')
.get('/health', () => ({
status: 'ok',
timestamp: new Date().toISOString(),
}));
app.listen(3000);
console.log(`Server running at ${app.server?.hostname}:${app.server?.port}`);
Production Elysia Stack
bun add elysia @elysiajs/cors @elysiajs/jwt @elysiajs/swagger
bun add drizzle-orm postgres
bun add --dev drizzle-kit @types/bun
Schema-First with Automatic Types
// src/db/schema.ts:
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
email: text('email').notNull().unique(),
name: text('name').notNull(),
plan: text('plan', { enum: ['free', 'pro', 'team'] }).notNull().default('free'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').default(false).notNull(),
authorId: text('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// src/db/index.ts:
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
Building a Full API with Plugins
// src/routes/posts.ts — Elysia plugin:
import { Elysia, t } from 'elysia';
import { db } from '../db';
import { posts } from '../db/schema';
import { eq, desc } from 'drizzle-orm';
export const postsPlugin = new Elysia({ prefix: '/posts' })
.get('/', async () => {
return db.select().from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(20);
})
.get('/:id', async ({ params: { id }, error }) => {
const [post] = await db.select().from(posts).where(eq(posts.id, id));
if (!post) return error(404, { message: 'Post not found' });
return post;
}, {
params: t.Object({ id: t.String() }),
})
.post('/', async ({ body, error }) => {
try {
const [post] = await db.insert(posts).values({
title: body.title,
content: body.content,
authorId: body.authorId,
}).returning();
return post;
} catch (e) {
return error(500, { message: 'Failed to create post' });
}
}, {
body: t.Object({
title: t.String({ minLength: 1, maxLength: 200 }),
content: t.String({ minLength: 1 }),
authorId: t.String(),
}),
});
// src/index.ts — compose plugins:
import { Elysia } from 'elysia';
import { cors } from '@elysiajs/cors';
import { jwt } from '@elysiajs/jwt';
import { swagger } from '@elysiajs/swagger';
import { postsPlugin } from './routes/posts';
import { authPlugin } from './routes/auth';
const app = new Elysia()
.use(cors({ origin: process.env.FRONTEND_URL }))
.use(swagger({ documentation: { info: { title: 'My API', version: '1.0.0' } } }))
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET! }))
.use(authPlugin)
.group('/api', (app) => app
.use(postsPlugin)
)
.listen(3000);
// Export for Eden treaty type inference:
export type App = typeof app;
console.log(`Server on http://localhost:${app.server?.port}`);
Database Integration Patterns
Drizzle is the standard ORM choice for ElysiaJS projects in 2026 — it's the only major TypeScript ORM with first-class Bun support. Prisma works with Bun through the compatibility layer, but the query engine startup overhead is noticeable. Drizzle is pure TypeScript with no native binaries, making it inherently compatible.
The recommended Drizzle setup for production:
// src/db/index.ts — connection pooling for production
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
// Different pool sizes for different environments
const pool = postgres(process.env.DATABASE_URL!, {
max: process.env.NODE_ENV === 'production' ? 10 : 3,
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(pool, { schema });
// Graceful shutdown — close pool when process exits
process.on('SIGTERM', () => pool.end());
process.on('SIGINT', () => pool.end());
// src/db/migrate.ts — run migrations on startup
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(migrationClient);
await migrate(db, { migrationsFolder: './drizzle' });
await migrationClient.end();
console.log('Database migrations complete');
// package.json — migration scripts
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "bun run src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"start": "bun run src/db/migrate.ts && bun run src/index.ts"
}
}
Running migrations before server startup (start script above) ensures the database is always in sync before accepting requests. This is safer than manual migration steps in deployment pipelines.
Eden Treaty: The Killer Feature
// Frontend — zero-config type-safe client:
import { treaty } from '@elysiajs/eden';
import type { App } from '../server/src/index'; // Import server types
const client = treaty<App>('http://localhost:3000');
// Types are inferred from the Elysia schema above:
const { data: posts, error } = await client.api.posts.get();
// posts is: Array<{ id: string; title: string; content: string; ... }>
const { data: newPost } = await client.api.posts.post({
title: 'Hello World',
content: 'My first post',
authorId: currentUser.id,
});
// TypeScript error if body doesn't match Elysia's t.Object schema
// Params:
const { data: post } = await client.api.posts({ id: 'post-123' }).get();
No codegen, no schema files, no separate type declarations — types flow directly from Elysia's route definitions to the client.
Middleware, Guards, and Rate Limiting
Elysia's middleware system is more ergonomic than Express's for type-safe APIs. Guards let you validate and transform context before reaching route handlers:
// src/middleware/auth.ts — JWT guard
import { Elysia } from 'elysia';
import { jwt } from '@elysiajs/jwt';
export const authGuard = new Elysia({ name: 'auth-guard' })
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET! }))
.derive(async ({ jwt, headers, error }) => {
const token = headers.authorization?.replace('Bearer ', '');
if (!token) return error(401, { message: 'Authentication required' });
const payload = await jwt.verify(token);
if (!payload) return error(401, { message: 'Invalid token' });
return { user: payload as { userId: string; email: string; plan: string } };
});
// Apply to routes that need auth:
export const protectedPlugin = new Elysia({ prefix: '/protected' })
.use(authGuard)
.get('/profile', ({ user }) => ({
id: user.userId,
email: user.email,
plan: user.plan,
}));
// user is fully typed from the JWT payload
For rate limiting without Upstash Redis, Bun's built-in Map works well for in-memory rate limiting in single-instance deployments:
// src/middleware/rate-limit.ts — in-memory rate limiter
const requests = new Map<string, { count: number; resetAt: number }>();
export const rateLimitGuard = new Elysia({ name: 'rate-limit' })
.derive(({ request, set, error }) => {
// Use user ID from auth or IP for unauthenticated requests
const key = request.headers.get('x-forwarded-for') ?? 'anonymous';
const now = Date.now();
const window = 60_000; // 1 minute
const current = requests.get(key);
if (!current || current.resetAt < now) {
requests.set(key, { count: 1, resetAt: now + window });
return {};
}
if (current.count >= 100) {
set.headers['Retry-After'] = String(Math.ceil((current.resetAt - now) / 1000));
return error(429, { message: 'Rate limit exceeded' });
}
current.count++;
return {};
});
Note: in-memory rate limiting doesn't work in multi-instance deployments (each instance has its own counter). For multi-instance production, use Upstash Redis with the @upstash/ratelimit library.
Production Readiness: What to Know Before Committing
Before choosing ElysiaJS for a production project, understand the current limitations:
Bun runtime requirement: ElysiaJS requires Bun. There's no stable path to running it on Node.js in production. This is fine for greenfield projects but complicates migration if you later need to move to a Node.js-compatible runtime (Lambda, some Kubernetes environments with restricted runtimes).
Native module compatibility: Some npm packages use native Node.js add-ons (compiled C++ code via node-gyp). These don't work in Bun without workarounds. The most common SaaS libraries — Prisma, Drizzle, Stripe, Resend, Zod — all work fine. The edge cases are more obscure: certain PDF generation libraries, some image processing tools, enterprise database drivers. Check compatibility before building on a dependency.
Ecosystem size: ElysiaJS has an active community but is small compared to Express/Fastify. If you encounter a production issue at 2am, the likelihood of finding a Stack Overflow answer or existing GitHub issue is lower than for more established frameworks. The official documentation is good, but community resources are still building.
Production stability: ElysiaJS 1.x has been production-tested by a growing number of companies. The framework is not experimental — but it's also not at the maturity level of Fastify or Hono, which have years of production use across thousands of deployments.
For teams where these constraints are acceptable (dedicated Bun runtime, standard npm ecosystem, smaller team with direct framework access), ElysiaJS is production-ready.
ElysiaJS vs Hono vs Fastify: The Decision
The three frameworks compete for the same space — TypeScript-first, high-performance HTTP APIs. The differences matter at the margins:
ElysiaJS + Bun: Best raw performance (500K+ req/s), best end-to-end type safety via Eden treaty, but locked to Bun runtime. Choose if you want maximum performance and are committed to Bun in production. The Eden treaty's zero-codegen type sharing is genuinely unique — no other framework offers this without a build step.
Hono: Best portability. Runs on Bun, Node.js, Cloudflare Workers, Deno, and AWS Lambda from the same codebase. The largest boilerplate ecosystem of the three. Slightly less performance than ElysiaJS on Bun (300K req/s vs 500K) but more than fast enough for any practical use case. Choose if you need runtime flexibility or plan to deploy to Cloudflare Workers.
Fastify: Most mature, largest ecosystem, best plugin ecosystem (rate limiting, caching, authentication all have first-class plugins). Runs on Node.js, meaning the full npm ecosystem is available without compatibility concerns. The standard choice for teams that need stability and have been burned by Node.js compatibility issues with newer runtimes.
The performance differences between all three are irrelevant for most applications — API response times are dominated by database query time (10-200ms), not framework overhead (sub-millisecond). Choose based on ecosystem needs, deployment target, and team familiarity.
For greenfield projects in 2026 where Bun is acceptable: ElysiaJS wins on DX. For projects that need universal deployment or have existing Node.js infrastructure: Hono. For teams that want the most battle-tested option with the largest plugin ecosystem: Fastify. All three are actively maintained with growing communities — any of them is a sound foundation for a production API in 2026.
Authentication Implementation with JWT
The official Elysia starter doesn't include auth. The recommended pattern for API-key or JWT authentication:
// src/routes/auth.ts
import { Elysia, t } from 'elysia';
import { jwt } from '@elysiajs/jwt';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
export const authPlugin = new Elysia({ prefix: '/auth' })
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET! }))
.post('/login', async ({ body, jwt, error }) => {
const [user] = await db.select()
.from(users)
.where(eq(users.email, body.email));
if (!user) return error(401, { message: 'Invalid credentials' });
const validPassword = await Bun.password.verify(body.password, user.passwordHash);
if (!validPassword) return error(401, { message: 'Invalid credentials' });
const token = await jwt.sign({
userId: user.id,
email: user.email,
plan: user.plan,
});
return { token, user: { id: user.id, email: user.email, plan: user.plan } };
}, {
body: t.Object({
email: t.String({ format: 'email' }),
password: t.String({ minLength: 8 }),
}),
})
.post('/register', async ({ body, jwt, error }) => {
const existing = await db.select().from(users).where(eq(users.email, body.email));
if (existing.length > 0) return error(409, { message: 'Email already registered' });
const passwordHash = await Bun.password.hash(body.password);
const [user] = await db.insert(users).values({
email: body.email,
name: body.name,
passwordHash,
}).returning();
const token = await jwt.sign({ userId: user.id, email: user.email, plan: user.plan });
return { token, user: { id: user.id, email: user.email, plan: user.plan } };
}, {
body: t.Object({
email: t.String({ format: 'email' }),
name: t.String({ minLength: 2 }),
password: t.String({ minLength: 8 }),
}),
});
Bun's built-in Bun.password.hash() and Bun.password.verify() use Argon2id by default — the current recommended password hashing algorithm — with no additional dependencies. This is one of the advantages of Bun over Node.js: password hashing, UUID generation, and cryptographic operations are built in.
Error Handling and Validation
Elysia's built-in validation with Typebox (t.Object) provides automatic 400 responses for malformed requests. The error handling pattern for production:
// src/index.ts — global error handler
const app = new Elysia()
.onError(({ code, error, set }) => {
// Log all server errors
if (code === 'UNKNOWN' || set.status >= 500) {
console.error('[Server Error]', error);
}
// Structured error responses
switch (code) {
case 'NOT_FOUND':
return { error: 'Resource not found', code: 'NOT_FOUND' };
case 'VALIDATION':
return { error: 'Validation failed', details: error.message, code: 'VALIDATION_ERROR' };
case 'UNAUTHORIZED':
return { error: 'Authentication required', code: 'UNAUTHORIZED' };
default:
if (set.status >= 500) {
return { error: 'Internal server error', code: 'INTERNAL_ERROR' };
}
return { error: error.message, code: 'ERROR' };
}
})
// ... routes
Good error messages are especially important for developer-facing APIs. When a developer gets a 400 Validation failed response with details: "body.email: Expected string, received undefined", they know exactly what to fix. Generic 400 Bad Request without a message leads to support tickets.
Deployment: Bun on Railway
# Dockerfile — minimal Bun production image:
FROM oven/bun:1 as base
WORKDIR /app
FROM base as install
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
FROM base as release
COPY --from=install /app/node_modules node_modules
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]
# railway.toml:
[build]
builder = "DOCKERFILE"
[deploy]
startCommand = "bun run src/index.ts"
healthcheckPath = "/health"
Structuring a Production ElysiaJS Project
The bun create elysia starter gives you a single index.ts. For production, you need a structure that scales with team size and feature count:
src/
├── index.ts # App entry point — compose all plugins
├── db/
│ ├── index.ts # Database connection
│ ├── schema.ts # Drizzle schema definitions
│ └── migrate.ts # Migration runner
├── routes/
│ ├── auth.ts # Auth plugin (register, login, logout)
│ ├── users.ts # User management endpoints
│ ├── billing.ts # Stripe webhook + billing endpoints
│ └── api/
│ ├── v1/
│ │ ├── index.ts # v1 API group
│ │ ├── resources.ts
│ │ └── webhooks.ts
├── middleware/
│ ├── auth-guard.ts # JWT validation + user injection
│ ├── rate-limit.ts # Rate limiting middleware
│ └── logger.ts # Request logging
├── lib/
│ ├── email.ts # Resend email sending
│ ├── stripe.ts # Stripe client
│ └── validators.ts # Shared Zod/Elysia type schemas
└── types/
└── index.ts # Shared TypeScript types
The key architectural principle: each route file exports an Elysia plugin with a prefix. The main index.ts composes these plugins. This keeps files focused and makes it easy to find where any endpoint lives.
For API versioning, nest v1 routes under an /api/v1 prefix group from day one:
// src/index.ts
const app = new Elysia()
.use(cors())
.use(swagger())
.use(authPlugin)
.group('/api', app =>
app.group('/v1', app =>
app
.use(resourcesPlugin)
.use(webhooksPlugin)
)
)
.listen(3000);
Adding /api/v2 later requires a new group with different route handlers — existing v1 consumers are unaffected.
Starter Templates Comparison
| Starter | Base | Database | Auth | DX |
|---|---|---|---|---|
bun create elysia | Official | None | None | ⭐⭐⭐⭐ |
| Custom Elysia + Drizzle | Community | Drizzle + Postgres | JWT | ⭐⭐⭐⭐⭐ |
| Elysia + Lucia | Community | Drizzle/Prisma | Lucia v3 | ⭐⭐⭐ |
| ElysiaJS fullstack | Community | Drizzle | JWT | ⭐⭐⭐ |
The honest picture in 2026: there isn't a "ShipFast for ElysiaJS" — a polished, maintained boilerplate with auth, billing, email, and dashboard pre-built. The community starters are functional starting points, but most teams end up composing their own stack. The official starter gets you the server runtime; you add Drizzle, auth, and API key management yourself following the patterns in this guide.
Testing Elysia APIs
ElysiaJS ships with a built-in test helper that lets you call route handlers without starting an HTTP server — similar to supertest for Express but with full TypeScript types:
// src/routes/posts.test.ts
import { describe, expect, it, beforeAll } from 'bun:test';
import { Elysia } from 'elysia';
import { postsPlugin } from './posts';
describe('Posts API', () => {
const app = new Elysia().use(postsPlugin);
it('returns list of published posts', async () => {
const response = await app.handle(
new Request('http://localhost/posts')
);
expect(response.status).toBe(200);
const posts = await response.json();
expect(Array.isArray(posts)).toBe(true);
});
it('returns 404 for non-existent post', async () => {
const response = await app.handle(
new Request('http://localhost/posts/non-existent-id')
);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.message).toBe('Post not found');
});
it('validates request body', async () => {
const response = await app.handle(
new Request('http://localhost/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '', // Empty string — should fail minLength: 1 validation
content: 'Content',
authorId: 'user-123',
}),
})
);
expect(response.status).toBe(422); // Elysia returns 422 for validation errors
});
});
Run tests with bun test — Bun has a built-in test runner that's significantly faster than Jest for TypeScript projects. Test files are automatically discovered by the *.test.ts pattern.
Common Production Issues
Teams that have deployed ElysiaJS to production report a consistent set of issues:
Memory usage with long-lived Bun processes: Bun's garbage collector is less mature than V8's. Long-lived production processes (days or weeks) can show memory growth. The mitigation is to configure Railway or Fly.io to restart your service weekly during off-peak hours. This is a known issue the Bun team is actively working on, and has improved significantly between Bun 1.0 and 1.2.
Hot reload in development: bun --hot provides hot module reloading during development, but it has edge cases with module caching — particularly with singleton instances like database connections. If you see stale connections or unexpected behavior during development, a full restart (bun run src/index.ts without --hot) usually resolves it.
TypeScript strict mode: Elysia's type inference works best with strict TypeScript settings. If you're migrating an existing project that wasn't using strict TypeScript, expect type errors during migration that reveal real bugs in your application logic — not false positives.
When ElysiaJS Makes Sense
Choose ElysiaJS + Bun if:
→ Committed to Bun runtime in production
→ Want fastest possible API throughput
→ E2E type safety without tRPC
→ Building a new service (greenfield)
→ Railway/Fly.io deployment (Bun supported)
Choose Hono instead if:
→ Need Node.js compatibility guarantee
→ Deploying to Cloudflare Workers
→ Want larger boilerplate ecosystem
→ Team not ready for Bun edge cases
Choose T3/tRPC if:
→ Next.js full-stack app
→ Need NextAuth integration
→ Largest ecosystem
The decision usually comes down to one question: are you deploying to an environment where you control the runtime? If you're on Railway, Fly.io, Render, or your own VPS with Docker — Bun is fully supported and ElysiaJS is a strong choice. If you're on Vercel, AWS Lambda, or any runtime that's locked to Node.js — choose Hono or Fastify.
Related Resources
For Hono as an alternative — with better Cloudflare Workers support and larger boilerplate ecosystem — best boilerplates for developer tools and APIs covers the Hono + OpenAPI stack in detail. For adding API key authentication to an ElysiaJS backend using the same patterns as Unkey, usage-based billing with Stripe covers the per-request metering approach that works with any API framework. For comparing ElysiaJS against tRPC for full-stack type safety, best boilerplates for developer tools and APIs covers the tRPC + OpenAPI adapter approach for teams committed to Next.js.
Methodology
Performance benchmarks sourced from TechEmpower Framework Benchmarks (Round 22) and community-maintained Bun vs Node.js benchmarks on GitHub. Starter template comparison based on direct evaluation of each project's GitHub repository as of Q1 2026. Bun compatibility notes derived from the official Bun compatibility documentation and community issue trackers. Bun's Windows support improved substantially through 2025, and major Node.js compatibility gaps have been closed — teams previously blocked by Windows development environment issues or specific Node.js module dependencies should re-evaluate Bun's current compatibility status before ruling it out.
The boilerplate and tool choices covered here represent the most actively maintained options in their category as of 2026. Evaluate each against your specific requirements: team expertise, deployment infrastructure, budget, and the features your product requires on day one versus those you can add incrementally. The best starting point is the one that lets your team ship the first version of your product fastest, with the least architectural debt.
Find ElysiaJS and Bun boilerplates at StarterPick.