| name | bunjs |
| description | Provides Bun.js/Hono patterns — HTTP endpoints, Prisma/SQLite, Zod validation, Bun test. Use when building a Bun.js service or wiring Hono routes with Prisma. |
| disable-model-invocation | true |
Bun.js Backend Patterns
Overview
Bun runtime patterns for building fast TypeScript backend services. This skill covers core Bun features, HTTP servers with Hono, database access with Prisma, validation with Zod, error handling, testing, and configuration patterns.
When to use this skill:
- Implementing basic HTTP endpoints and route handlers
- Setting up middleware patterns (CORS, logging, auth)
- Working with SQLite or PostgreSQL databases
- Implementing request validation with Zod
- Writing tests with Bun's native test runner
- Basic file operations and WebSocket handling
For advanced topics, see:
- dev:bunjs-architecture - Layered architecture, clean code patterns, camelCase conventions
- dev:bunjs-production - Docker, AWS, Redis caching, security, CI/CD
- dev:bunjs-apidog - OpenAPI specs and Apidog integration
Why Bun
Bun fundamentally transforms TypeScript backend development by:
- Native TypeScript execution - No build steps in development
- Lightning-fast performance - 3-4x faster than Node.js for many operations
- Unified toolkit - Built-in test runner, bundler, and transpiler
- Drop-in compatibility - Most Node.js APIs and npm packages work
- Developer experience - Hot reload with
--hot, instant feedback
Stack Overview
- Bun 1.x (runtime, package manager, test runner, bundler)
- TypeScript 5.7 (strict mode)
- Hono 4.6 (ultra-fast web framework, TypeScript-first)
- Prisma 6.2 (type-safe ORM)
- Biome 2.3 (formatting + linting, replaces ESLint + Prettier)
- Zod (runtime validation)
- PostgreSQL 17 / SQLite (database)
Project Structure
project-root/
├── src/
│ ├── server.ts # Entry point (starts server)
│ ├── app.ts # Hono app initialization & middleware
│ ├── config.ts # Environment configuration
│ ├── core/ # Core utilities (errors, logger, responses)
│ ├── database/
│ │ ├── client.ts # Prisma client setup
│ │ └── repositories/ # Data access layer (Prisma queries)
│ ├── services/ # Business logic layer
│ ├── controllers/ # HTTP handlers (calls services)
│ ├── middleware/ # Hono middleware (auth, validation, etc.)
│ ├── routes/ # API route definitions
│ ├── schemas/ # Zod validation schemas
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Utility functions
├── tests/
│ ├── unit/ # Unit tests
│ └── integration/ # Integration tests (API + DB)
├── prisma/ # Prisma schema & migrations
├── tsconfig.json # TypeScript config
├── biome.json # Biome config
├── package.json # Bun-managed dependencies
└── bun.lockb # Bun lockfile
Key Principles:
- Structure by technical capability, not by feature
- Each layer has single responsibility
- No HTTP handling in services, no business logic in controllers
- Easy to test components in isolation
Quick Start
bun init
bun add hono @hono/node-server zod @prisma/client bcrypt jsonwebtoken
bun add -d @types/node @types/jsonwebtoken @types/bcrypt typescript prisma @biomejs/biome @types/bun
bunx tsc --init
bunx prisma init
bunx @biomejs/biome init
package.json scripts:
{
"scripts": {
"dev": "bun --hot src/server.ts",
"start": "NODE_ENV=production bun src/server.ts",
"build": "bun build src/server.ts --target bun --outdir dist",
"test": "bun test",
"test:watch": "bun test --watch",
"lint": "biome lint --write",
"format": "biome format --write",
"check": "biome check --write",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
}
}
TypeScript Configuration
tsconfig.json (key settings):
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns":
Critical settings:
"strict": true - Enable all strict checks
"moduleResolution": "bundler" - Aligns with Bun's resolver
- Use
paths for clean imports (@core/*, @services/*)
HTTP Server with Hono
Basic Server Setup
Entry point (src/server.ts):
import { serve } from '@hono/node-server';
import { app } from './app';
const PORT = Number(process.env.PORT) || 3000;
serve({ fetch: app.fetch, port: PORT });
console.log(`🚀 Server running on port ${PORT}`);
App initialization (src/app.ts):
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import userRouter from './routes/user.routes';
export const app = new Hono();
app.use('*', logger());
app.use('*', cors({
origin: ['http://localhost:3000'],
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
credentials: true
}));
app.get('/health', (c) => c.json({ status: 'ok' }));
app.route('/api/users', userRouter);
Route Handlers
Routes (src/routes/user.routes.ts):
import { Hono } from 'hono';
import * as userController from '../controllers/user.controller';
import { validate } from '../middleware/validator';
import { createUserSchema } from '../schemas/user.schema';
const userRouter = new Hono();
userRouter.get('/', userController.getUsers);
userRouter.get('/:id', userController.getUserById);
userRouter.post('/', validate(createUserSchema), userController.createUser);
userRouter.put('/:id', userController.updateUser);
userRouter.delete('/:id', userController.deleteUser);
export default userRouter;
Controllers (src/controllers/user.controller.ts):
import type { Context } from 'hono';
import * as userService from '../services/user.service';
export const createUser = async (c: Context) => {
const data = c.get('validatedData');
const user = await userService.createUser(data);
return c.json(user, 201);
};
export const getUserById = async (c: Context) => {
const id = c.req.param('id');
const user = await userService.getUserById(id);
return c.json(user);
};
export const getUsers = async (c: Context) => {
const page = Number(c.req.query('page')) || 1;
limit = (c..()) || ;
result = userService.({ page, limit });
c.(result);
};
= () => {
id = c..();
data = c..();
user = userService.(id, data);
c.(user);
};
= () => {
id = c..();
userService.(id);
c.({ : });
};
Middleware Patterns
Validation Middleware
src/middleware/validator.ts:
import { z, ZodSchema } from 'zod';
import type { Context, Next } from 'hono';
export const validate = (schema: ZodSchema) => async (c: Context, next: Next) => {
try {
const body = await c.req.json();
c.set('validatedData', schema.parse(body));
await next();
} catch (e) {
if (e instanceof z.ZodError) {
return c.json({ error: 'Validation failed', details: e.issues }, 422);
}
throw e;
}
};
export const validateQuery = (schema: ZodSchema) => async (c: Context, next: Next) => {
{
c.(, schema.(c..()));
();
} (e) {
(e z.) {
c.({ : , : e. }, );
}
e;
}
};
Authentication Middleware
src/middleware/auth.ts:
import type { Context, Next } from 'hono';
import { verifyToken } from '../services/auth.service';
export const authenticate = async (c: Context, next: Next) => {
const header = c.req.header('Authorization');
if (!header?.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid token' }, 401);
}
try {
const token = header.slice(7);
const payload = await verifyToken(token);
c.set('user', payload);
await next();
} catch {
return c.json({ error: 'Invalid or expired token' }, 401);
}
};
export const authorize = (...roles: string[]) => (: , : ) => {
user = c.() { : } | ;
(!user) {
c.({ : }, );
}
(!roles.(user.)) {
c.({ : }, );
}
();
};
Logging Middleware
src/middleware/requestLogger.ts:
import type { Context, Next } from 'hono';
export const requestLogger = async (c: Context, next: Next) => {
const start = Date.now();
const requestId = crypto.randomUUID();
c.set('requestId', requestId);
console.log(`[${requestId}] ${c.req.method} ${c.req.path}`);
await next();
const duration = Date.now() - start;
console.log(`[${requestId}] ${c.res.status} ${duration}ms`);
};
Database Access
SQLite with Bun
import { Database } from 'bun:sqlite';
const db = new Database('app.db');
db.exec('PRAGMA journal_mode = WAL');
const findUserById = db.prepare<{ id: string }, [string]>(
'SELECT * FROM users WHERE id = ?'
);
const createUser = db.prepare<void, [string, string, string]>(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?)'
);
export const userRepository = {
findById(id: string) {
return findUserById.get(id);
},
create(user: { id: string; name: string; email: string }) {
createUser.run(user.id, user.name, user.email);
return user;
},
findAll(options: { limit: number; offset: number }) {
return db.(
).(options., options.);
},
};
PostgreSQL with Prisma
Prisma client setup (src/database/client.ts):
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
process.on('beforeExit', async () => {
await prisma.$disconnect();
});
Repository pattern (src/database/repositories/user.repository.ts):
import { prisma } from '../client';
import type { Prisma, User } from '@prisma/client';
export class UserRepository {
findById(id: string): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}
findByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({ where: { email } });
}
create(data: Prisma.UserCreateInput) {
return prisma.user.create({ data });
}
update(id: string, data: Prisma.UserUpdateInput) {
return prisma.user.update({ where: { id }, data });
}
async delete() {
prisma..({ : { id } });
}
() {
( prisma..({ : { email } })) > ;
}
() {
[users, total] = prisma.$transaction([
prisma..(options),
prisma..({ : options. })
]);
{ users, total };
}
}
userRepository = ();
Prisma commands:
bunx prisma generate
bunx prisma migrate dev
bunx prisma migrate deploy
bunx prisma studio
bunx prisma db seed
bunx prisma format
Validation with Zod
Validation schemas (src/schemas/user.schema.ts):
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string()
.min(8)
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[a-z]/, 'Password must contain lowercase letter')
.regex(/[0-9]/, 'Password must contain number')
.regex(/[^A-Za-z0-9]/, 'Password must contain special character'),
name: z.string().min(2).max(100),
role: z.enum(['user', 'admin', 'moderator']).default('user')
});
export const updateUserSchema = createUserSchema.partial();
export const getUsersQuerySchema = z.object({
page: z.coerce.number().positive().default(1),
: z..().().().(),
: z.([, , ]).(),
: z.([, ]).(),
: z.([, , ]).()
});
= z.< createUserSchema>;
= z.< updateUserSchema>;
= z.< getUsersQuerySchema>;
Why Zod:
- Runtime type validation (catches invalid data at boundaries)
- TypeScript type inference (
z.infer<typeof schema>)
- Clear error messages for users
- Composable schemas (
.partial(), .extend(), .pick())
Error Handling
Custom error classes (src/core/errors.ts):
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500
) {
super(message);
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 'NOT_FOUND', 404);
}
}
export class ValidationError extends AppError {
constructor(message: string, public errors: any) {
super(message, 'VALIDATION_ERROR', 422);
}
}
export class UnauthorizedError extends AppError {
constructor(message = ) {
(message, , );
}
}
{
() {
(message, , );
}
}
Global error handler (src/middleware/errorHandler.ts):
import type { Context } from 'hono';
import { AppError } from '../core/errors';
export function errorHandler(err: Error, c: Context) {
if (err instanceof AppError) {
return c.json({
error: {
code: err.code,
message: err.message,
...(err instanceof ValidationError && { details: err.errors })
}
}, err.statusCode);
}
console.error('Unexpected error:', err);
return c.json({
error: {
code: 'INTERNAL_ERROR',
message: 'Internal server error'
}
}, 500);
}
app.onError(errorHandler);
Testing with Bun
Bun includes a fast, built-in test runner with Jest-like APIs.
Unit test example (tests/unit/services/user.service.test.ts):
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { createUser, getUserById } from '../../../src/services/user.service';
import { prisma } from '../../../src/database/client';
describe('UserService', () => {
beforeAll(async () => {
await prisma.$connect();
});
afterAll(async () => {
await prisma.user.deleteMany();
await prisma.$disconnect();
});
test('createUser creates a user and strips password', async () => {
const user = await createUser({
email: 'test@example.com',
password: 'Abcdef1!',
name: 'Test User',
role: 'user'
});
expect(user).toHaveProperty('email', 'test@example.com');
expect(user).toHaveProperty('name', 'Test User');
expect(user).not.toHaveProperty('password');
});
(, () => {
(())..();
});
});
Integration test example (tests/integration/api/user.test.ts):
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { app } from '../../../src/app';
import { prisma } from '../../../src/database/client';
describe('User API', () => {
beforeAll(async () => {
await prisma.$connect();
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.user.deleteMany();
await prisma.$disconnect();
});
test('POST /api/users creates a user', async () => {
const res = await app.request('http://localhost/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'new@example.com',
password: 'Abcdef1!',
name: 'New User'
})
});
expect(res.status).toBe();
body = res.();
(body.).();
(body)..();
});
(, () => {
res = app.(, {
: ,
: { : },
: .({ : })
});
(res.).();
body = res.();
(body.).();
});
(, () => {
created = app.(, {
: ,
: { : },
: .({
: ,
: ,
:
})
});
createdBody = created.();
res = app.();
(res.).();
body = res.();
(body.).(createdBody.);
});
});
Test commands:
bun test
bun test --watch
bun test --coverage
bun test user.test.ts
Configuration
Environment configuration (src/config.ts):
const config = {
port: parseInt(process.env.PORT || '3000'),
nodeEnv: process.env.NODE_ENV || 'development',
database: {
url: process.env.DATABASE_URL || 'postgres://localhost/app',
maxConnections: parseInt(process.env.DB_MAX_CONNECTIONS || '20'),
},
jwt: {
secret: process.env.JWT_SECRET || 'development-secret',
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
};
export default config;
.env files:
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
JWT_SECRET="dev-secret"
NODE_ENV="development"
DATABASE_URL="postgresql://user:password@prod-host:5432/mydb"
JWT_SECRET="strong-random-secret"
NODE_ENV="production"
File Operations
File uploads:
router.post('/upload', async (c) => {
const formData = await c.req.formData();
const file = formData.get('file') as File;
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return c.json({ error: 'Invalid file type' }, 400);
}
const filename = `${crypto.randomUUID()}-${file.name}`;
await Bun.write(`./uploads/${filename}`, file);
return c.json({
data: { filename, size: file.size, type: file.type }
});
});
Reading files:
const data = await Bun.file('data.json').json();
const text = await Bun.file('README.md').text();
const buffer = await Bun.file('image.png').arrayBuffer();
WebSocket
const server = Bun.serve({
port: 3000,
fetch(req, server) {
if (req.url.endsWith('/ws')) {
const upgraded = server.upgrade(req);
if (!upgraded) {
return new Response('WebSocket upgrade failed', { status: 400 });
}
return undefined;
}
return app.fetch(req);
},
websocket: {
open(ws) {
console.log('Client connected');
},
message(ws, message) {
console.log('Received:', message);
ws.send(`Echo: ${message}`);
},
close(ws) {
console.log('Client disconnected');
},
},
});
Quality Checks
Before presenting code, run these checks:
bun run format
bun run lint
bun run typecheck
bun test
bunx prisma generate
Code Quality with Biome
biome.json:
{
"$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
"files": {
"ignore": ["node_modules", "dist", ".next"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter":
Commands:
bun run check
bun run lint
bun run format
Bun.js patterns for fast TypeScript backend development. For advanced architecture patterns, see dev:bunjs-architecture. For production deployment, see dev:bunjs-production.