How to Build an API with Bun | Fast TypeScript Server Guide

Build a high-performance REST API with Bun's native HTTP server, TypeScript, SQLite, and JWT authentication in under 3 hours.

~2-3 hoursbeginner8 steps
Share:XLinkedIn

Bun is a JavaScript runtime designed for speed. Its built-in HTTP server, native SQLite driver, and first-class TypeScript support mean you can build a complete API without installing any external dependencies. Bun's HTTP server handles over 100,000 requests per second on modest hardware, making it one of the fastest options for building APIs in the JavaScript ecosystem. This guide builds a complete REST API with CRUD operations, JWT authentication, validation, and error handling.

Prerequisites

  • Bun 1.1+

    Install Bun, the all-in-one JavaScript runtime with built-in bundler, test runner, and package manager.

  • Basic TypeScript Knowledge

    Bun runs TypeScript natively without a compilation step, so familiarity with TypeScript syntax is helpful.

  • REST API Concepts

    Understanding of HTTP methods (GET, POST, PUT, DELETE), status codes, and JSON request/response patterns.

01

Initialize the Bun Project

Create a new Bun project with TypeScript configuration. Bun has its own init command that generates a minimal project with package.json and tsconfig.json. No build step is needed because Bun executes TypeScript directly.

bash
mkdir my-api && cd my-api
bun init -y
mkdir -p src/{routes,middleware,db}

Tip: Bun runs .ts files natively — no need for ts-node, tsx, or a TypeScript build step.

Tip: Use 'bun run --hot src/index.ts' for hot reloading during development.

02

Create the HTTP Server with Routing

Build a lightweight HTTP server using Bun.serve() with a pattern-matching router. Bun's native HTTP server is built on top of uWebSockets, giving you exceptional performance. The router maps URL patterns and HTTP methods to handler functions without any external framework.

src/index.tstypescript
// src/index.ts
import { handleUsers } from "./routes/users";
import { handleAuth } from "./routes/auth";

const PORT = Number(Bun.env.PORT) || 3000;

Bun.serve({
  port: PORT,
  async fetch(req) {
    const url = new URL(req.url);
    const path = url.pathname;

    // CORS headers
    if (req.method === "OPTIONS") {
      return new Response(null, {
        headers: {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type, Authorization",
        },
      });
    }

    try {
      // Route matching
      if (path.startsWith("/api/auth")) {
        return await handleAuth(req, url);
      }
      if (path.startsWith("/api/users")) {
        return await handleUsers(req, url);
      }

      // Health check
      if (path === "/health") {
        return Response.json({ status: "ok", uptime: process.uptime() });
      }

      return Response.json({ error: "Not Found" }, { status: 404 });
    } catch (error) {
      console.error("Unhandled error:", error);
      return Response.json(
        { error: "Internal Server Error" },
        { status: 500 }
      );
    }
  },
});

console.log(`API server running on http://localhost:${PORT}`);

Tip: Bun.serve() returns a Server object you can use to gracefully shut down with server.stop().

Tip: The fetch handler uses the Web Standard Request/Response APIs — the same you would use in a Service Worker.

03

Set Up SQLite with Bun's Native Driver

Use Bun's built-in SQLite driver for zero-dependency database access. Bun's SQLite is implemented in native code and is significantly faster than node-sqlite3 or better-sqlite3. Create a database helper with type-safe query methods and automatic table creation.

src/db/index.tstypescript
// src/db/index.ts
import { Database } from "bun:sqlite";

const db = new Database("data.db", { create: true });

// Enable WAL mode for better concurrent read performance
db.run("PRAGMA journal_mode = WAL");

// Create tables
db.run(`
  CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    email TEXT UNIQUE NOT NULL,
    name TEXT NOT NULL,
    password_hash TEXT NOT NULL,
    role TEXT DEFAULT 'user',
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
  )
`);

db.run(`
  CREATE TABLE IF NOT EXISTS posts (
    id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    author_id TEXT NOT NULL REFERENCES users(id),
    published INTEGER DEFAULT 0,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
  )
`);

export { db };

// Type-safe query helpers
export interface User {
  id: string;
  email: string;
  name: string;
  password_hash: string;
  role: string;
  created_at: string;
  updated_at: string;
}

export interface Post {
  id: string;
  title: string;
  content: string;
  author_id: string;
  published: number;
  created_at: string;
  updated_at: string;
}

Tip: WAL (Write-Ahead Logging) mode allows concurrent reads while writing — essential for API workloads.

Tip: Bun's SQLite uses prepared statements by default, which prevents SQL injection and improves performance.

04

Implement JWT Authentication

Build authentication endpoints for user registration and login using Bun's built-in password hashing and a lightweight JWT library. Bun provides Bun.password.hash() and Bun.password.verify() which use bcrypt under the hood. JWTs are signed with HMAC-SHA256 for stateless authentication.

src/routes/auth.tstypescript
// src/routes/auth.ts
import { db } from "../db";
import type { User } from "../db";
import { signToken, verifyToken } from "../middleware/jwt";

export async function handleAuth(req: Request, url: URL): Promise<Response> {
  if (req.method === "POST" && url.pathname === "/api/auth/register") {
    const body = await req.json();
    const { email, name, password } = body;

    if (!email || !name || !password) {
      return Response.json(
        { error: "email, name, and password are required" },
        { status: 400 }
      );
    }

    const existing = db
      .query<User, [string]>("SELECT * FROM users WHERE email = ?")
      .get(email);
    if (existing) {
      return Response.json(
        { error: "Email already registered" },
        { status: 409 }
      );
    }

    const passwordHash = await Bun.password.hash(password, {
      algorithm: "bcrypt",
      cost: 10,
    });

    const stmt = db.prepare(
      "INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?) RETURNING id, email, name, role"
    );
    const user = stmt.get(email, name, passwordHash) as User;
    const token = await signToken({ userId: user.id, role: user.role });

    return Response.json({ user: { id: user.id, email: user.email, name: user.name }, token });
  }

  if (req.method === "POST" && url.pathname === "/api/auth/login") {
    const { email, password } = await req.json();

    const user = db
      .query<User, [string]>("SELECT * FROM users WHERE email = ?")
      .get(email);
    if (!user) {
      return Response.json({ error: "Invalid credentials" }, { status: 401 });
    }

    const valid = await Bun.password.verify(password, user.password_hash);
    if (!valid) {
      return Response.json({ error: "Invalid credentials" }, { status: 401 });
    }

    const token = await signToken({ userId: user.id, role: user.role });
    return Response.json({ user: { id: user.id, email: user.email, name: user.name }, token });
  }

  return Response.json({ error: "Not Found" }, { status: 404 });
}

Tip: Bun.password.hash() uses bcrypt by default with a configurable cost factor — 10 is a good balance of security and speed.

Tip: Never return the password_hash in API responses — select only the fields you need.

05

Build the JWT Middleware

Create a JWT utility module for signing and verifying tokens. Use the Web Crypto API (available in Bun) for HMAC-SHA256 signing, which is more portable than Node.js crypto. The middleware extracts the Bearer token from the Authorization header and validates it.

src/middleware/jwt.tstypescript
// src/middleware/jwt.ts
const SECRET = new TextEncoder().encode(
  Bun.env.JWT_SECRET || "change-me-in-production"
);

interface TokenPayload {
  userId: string;
  role: string;
}

export async function signToken(payload: TokenPayload): Promise<string> {
  const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
  const body = btoa(
    JSON.stringify({ ...payload, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 86400 })
  );
  const data = `${header}.${body}`;

  const key = await crypto.subtle.importKey(
    "raw",
    SECRET,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  const sig = btoa(String.fromCharCode(...new Uint8Array(signature)));

  return `${data}.${sig}`;
}

export async function verifyToken(token: string): Promise<TokenPayload | null> {
  try {
    const [header, body, sig] = token.split(".");
    const data = `${header}.${body}`;

    const key = await crypto.subtle.importKey(
      "raw",
      SECRET,
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"]
    );
    const signature = Uint8Array.from(atob(sig), (c) => c.charCodeAt(0));
    const valid = await crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(data));
    if (!valid) return null;

    const payload = JSON.parse(atob(body));
    if (payload.exp < Math.floor(Date.now() / 1000)) return null;

    return { userId: payload.userId, role: payload.role };
  } catch {
    return null;
  }
}

export async function authenticate(req: Request): Promise<TokenPayload | null> {
  const auth = req.headers.get("Authorization");
  if (!auth?.startsWith("Bearer ")) return null;
  return verifyToken(auth.slice(7));
}

Tip: Always set a JWT expiration (exp claim) — 24 hours is reasonable for API tokens.

Tip: Use a strong, random JWT_SECRET in production — generate one with 'openssl rand -base64 32'.

06

Build CRUD Routes for Users

Create a complete set of CRUD endpoints for the users resource. Each route checks authentication, validates input, and returns appropriate HTTP status codes. The route handler pattern extracts the resource ID from the URL path for single-resource operations.

src/routes/users.tstypescript
// src/routes/users.ts
import { db } from "../db";
import type { User } from "../db";
import { authenticate } from "../middleware/jwt";

export async function handleUsers(req: Request, url: URL): Promise<Response> {
  const auth = await authenticate(req);
  if (!auth) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const idMatch = url.pathname.match(/^\/api\/users\/([a-f0-9]+)$/);
  const userId = idMatch?.[1];

  // GET /api/users — List all users
  if (req.method === "GET" && !userId) {
    const page = Number(url.searchParams.get("page")) || 1;
    const limit = Math.min(Number(url.searchParams.get("limit")) || 20, 100);
    const offset = (page - 1) * limit;

    const users = db
      .query<Omit<User, "password_hash">, [number, number]>(
        "SELECT id, email, name, role, created_at FROM users LIMIT ? OFFSET ?"
      )
      .all(limit, offset);

    const total = db.query<{ count: number }, []>("SELECT COUNT(*) as count FROM users").get()!;

    return Response.json({
      data: users,
      pagination: { page, limit, total: total.count },
    });
  }

  // GET /api/users/:id — Get single user
  if (req.method === "GET" && userId) {
    const user = db
      .query<Omit<User, "password_hash">, [string]>(
        "SELECT id, email, name, role, created_at FROM users WHERE id = ?"
      )
      .get(userId);

    if (!user) {
      return Response.json({ error: "User not found" }, { status: 404 });
    }
    return Response.json({ data: user });
  }

  // PUT /api/users/:id — Update user
  if (req.method === "PUT" && userId) {
    if (auth.userId !== userId && auth.role !== "admin") {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }

    const { name } = await req.json();
    db.run(
      "UPDATE users SET name = ?, updated_at = datetime('now') WHERE id = ?",
      [name, userId]
    );

    return Response.json({ message: "Updated" });
  }

  // DELETE /api/users/:id — Delete user
  if (req.method === "DELETE" && userId) {
    if (auth.role !== "admin") {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }

    db.run("DELETE FROM users WHERE id = ?", [userId]);
    return Response.json({ message: "Deleted" });
  }

  return Response.json({ error: "Method not allowed" }, { status: 405 });
}

Tip: Always cap the pagination limit to prevent clients from requesting all records at once.

Tip: Return 403 Forbidden (not 404) when a user tries to access another user's data — this is clearer for API consumers.

07

Add Input Validation and Error Handling

Create a validation helper that checks request bodies against expected schemas. Proper validation prevents malformed data from reaching your database and gives API consumers clear error messages. Use a lightweight approach with TypeScript type guards rather than pulling in a heavy validation library.

src/middleware/validate.tstypescript
// src/middleware/validate.ts
type ValidationRule = {
  field: string;
  type: "string" | "number" | "boolean" | "email";
  required?: boolean;
  minLength?: number;
  maxLength?: number;
};

interface ValidationError {
  field: string;
  message: string;
}

export function validate(
  data: Record<string, unknown>,
  rules: ValidationRule[]
): ValidationError[] {
  const errors: ValidationError[] = [];

  for (const rule of rules) {
    const value = data[rule.field];

    if (rule.required && (value === undefined || value === null || value === "")) {
      errors.push({ field: rule.field, message: `${rule.field} is required` });
      continue;
    }

    if (value === undefined || value === null) continue;

    if (rule.type === "email" && typeof value === "string") {
      const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      if (!emailRegex.test(value)) {
        errors.push({ field: rule.field, message: "Invalid email format" });
      }
    }

    if (rule.type === "string" && typeof value !== "string") {
      errors.push({ field: rule.field, message: `${rule.field} must be a string` });
    }

    if (rule.minLength && typeof value === "string" && value.length < rule.minLength) {
      errors.push({
        field: rule.field,
        message: `${rule.field} must be at least ${rule.minLength} characters`,
      });
    }

    if (rule.maxLength && typeof value === "string" && value.length > rule.maxLength) {
      errors.push({
        field: rule.field,
        message: `${rule.field} must be at most ${rule.maxLength} characters`,
      });
    }
  }

  return errors;
}

// Usage:
// const errors = validate(body, [
//   { field: "email", type: "email", required: true },
//   { field: "name", type: "string", required: true, minLength: 2 },
//   { field: "password", type: "string", required: true, minLength: 8 },
// ]);
// if (errors.length) return Response.json({ errors }, { status: 400 });

Tip: Return all validation errors at once, not just the first one — this is a better UX for API consumers.

Tip: For production APIs, consider using Zod for schema validation — it works great with Bun.

08

Run Tests and Deploy

Write API tests using Bun's built-in test runner and deploy the API. Bun includes a Jest-compatible test runner that is significantly faster than Jest or Vitest. Tests can make real HTTP requests to your server for integration testing. Deploy to any VPS, Docker container, or Fly.io.

src/index.test.tstypescript
// src/index.test.ts
import { expect, test, describe, beforeAll, afterAll } from "bun:test";

const BASE_URL = "http://localhost:3001";
let server: ReturnType<typeof Bun.serve>;
let authToken: string;

beforeAll(async () => {
  // Start server on test port
  process.env.PORT = "3001";
  server = (await import("./index")).default;
});

afterAll(() => {
  server?.stop();
});

describe("Auth", () => {
  test("POST /api/auth/register creates a user", async () => {
    const res = await fetch(`${BASE_URL}/api/auth/register`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        email: "test@example.com",
        name: "Test User",
        password: "securepassword123",
      }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.token).toBeDefined();
    expect(data.user.email).toBe("test@example.com");
    authToken = data.token;
  });

  test("POST /api/auth/login returns a token", async () => {
    const res = await fetch(`${BASE_URL}/api/auth/login`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        email: "test@example.com",
        password: "securepassword123",
      }),
    });

    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.token).toBeDefined();
  });
});

describe("Users", () => {
  test("GET /api/users requires auth", async () => {
    const res = await fetch(`${BASE_URL}/api/users`);
    expect(res.status).toBe(401);
  });

  test("GET /api/users returns users when authenticated", async () => {
    const res = await fetch(`${BASE_URL}/api/users`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.data).toBeArray();
  });
});

Tip: Run tests with 'bun test' — it discovers and runs all .test.ts files automatically.

Tip: Deploy to Fly.io with 'fly launch' for a managed server, or use Docker: 'FROM oven/bun:1' as your base image.

Tip: Bun's SQLite file (data.db) persists to disk — mount a volume in your deployment for data durability.

Next Steps

  • Add rate limiting middleware to protect against abuse and DDoS attacks.
  • Implement request logging with structured JSON output for debugging and monitoring.
  • Add WebSocket support using Bun.serve's built-in WebSocket API for real-time features.
  • Set up OpenAPI/Swagger documentation generation for your API endpoints.