Build a REST API with Bun in 10 Minutes

Step-by-step guide to building a fast REST API using Bun, the all-in-one JavaScript runtime.

Kodetra TechnologiesKodetra Technologies
8 min read
Mar 30, 2026
0 views
Build a REST API with Bun in 10 Minutes

I replaced Node.js with Bun on a side project last month. The whole thing took twenty minutes, and my API cold-start time dropped from 300 ms to under 40 ms. I sat there staring at my terminal thinking, "Why didn't I try this sooner?"

If you've been hearing the buzz around Bun but haven't actually built anything with it yet, this tutorial is for you. We're going to build a fully working REST API from zero — no frameworks, no boilerplate generators, just Bun and a text editor.

Why Bun Matters Right Now

Bun isn't just another Node.js alternative. It's an all-in-one toolkit that bundles a JavaScript runtime, a package manager, a test runner, and a bundler into a single binary. It's written in Zig and powered by JavaScriptCore (the engine behind Safari), which gives it a serious speed advantage.

Here's what makes it stand out for backend work: Bun starts up roughly 4x faster than Node.js, installs dependencies up to 25x faster than npm, runs TypeScript natively without any transpilation step, and ships with a built-in SQLite driver. That last one is a game-changer for prototyping APIs. No external database setup, no Docker containers — just a file on disk.

What We're Building

We'll create a simple task management API with these endpoints:

GET    /tasks        → list all tasks
GET    /tasks/:id    → get a single task
POST   /tasks        → create a task
PUT    /tasks/:id    → update a task
DELETE /tasks/:id    → delete a task

Here's the request flow for our API:

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”     HTTP      ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”     SQL      ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Client   │ ──────────▶  │  Bun Server   │ ──────────▶ │  SQLite   │
│ (curl /   │              │  (serve())    │              │  (app.db) │
│  browser) │ ◀──────────  │  router logic │ ◀────────── │           │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜    JSON       ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜   results    ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

No frameworks. No ORMs. Just Bun's built-in APIs.

Step 1: Install Bun

If you don't have Bun installed yet, it's one command:

bash

curl -fsSL https://bun.sh/install | bash

On macOS you can also use Homebrew:

bash

brew install oven-sh/bun/bun

Verify the installation:

bash

bun --version

You should see something like 1.1.x or newer. That's it — no nvm, no version managers. Just a single binary.

Step 2: Scaffold the Project

Create a new directory and initialize it:

bash

mkdir bun-tasks-api && cd bun-tasks-api
bun init -y

This creates a minimal project structure:

bun-tasks-api/
ā”œā”€ā”€ index.ts
ā”œā”€ā”€ package.json
ā”œā”€ā”€ tsconfig.json
└── README.md

Notice that Bun scaffolds TypeScript by default. No ts-node, no tsx, no compilation step. You write .ts files and run them directly.

Step 3: Set Up the Database

Create a file called db.ts. We'll use Bun's built-in SQLite driver:

typescript

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

const db = new Database("tasks.db");

// Create the tasks table if it doesn't exist
db.run(`
  CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    completed INTEGER DEFAULT 0,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);

export default db;

That's it. No npm install better-sqlite3. No native bindings to compile. Bun ships with SQLite baked in, and the API is synchronous — which is actually fine for SQLite because it's in-process and blazing fast.

Step 4: Build the Router

Now let's write our server. Open index.ts and replace whatever's there:

typescript

// index.ts
import db from "./db";

const server = Bun.serve({
  port: 3000,

  async fetch(req: Request): Promise<Response> {
    const url = new URL(req.url);
    const path = url.pathname;
    const method = req.method;

    // Simple pattern matching for routes
    // GET /tasks
    if (method === "GET" && path === "/tasks") {
      const tasks = db.query("SELECT * FROM tasks ORDER BY created_at DESC").all();
      return Response.json(tasks);
    }

    // GET /tasks/:id
    if (method === "GET" && path.startsWith("/tasks/")) {
      const id = path.split("/")[2];
      const task = db.query("SELECT * FROM tasks WHERE id = ?").get(id);
      if (!task) {
        return Response.json({ error: "Task not found" }, { status: 404 });
      }
      return Response.json(task);
    }

    // POST /tasks
    if (method === "POST" && path === "/tasks") {
      const body = await req.json();
      if (!body.title || typeof body.title !== "string") {
        return Response.json(
          { error: "Title is required and must be a string" },
          { status: 400 }
        );
      }

      const result = db.run(
        "INSERT INTO tasks (title) VALUES (?)",
        [body.title.trim()]
      );

      const task = db.query("SELECT * FROM tasks WHERE id = ?").get(
        result.lastInsertRowid
      );
      return Response.json(task, { status: 201 });
    }

    // PUT /tasks/:id
    if (method === "PUT" && path.startsWith("/tasks/")) {
      const id = path.split("/")[2];
      const body = await req.json();

      const existing = db.query("SELECT * FROM tasks WHERE id = ?").get(id);
      if (!existing) {
        return Response.json({ error: "Task not found" }, { status: 404 });
      }

      db.run(
        "UPDATE tasks SET title = COALESCE(?, title), completed = COALESCE(?, completed) WHERE id = ?",
        [body.title ?? null, body.completed ?? null, id]
      );

      const updated = db.query("SELECT * FROM tasks WHERE id = ?").get(id);
      return Response.json(updated);
    }

    // DELETE /tasks/:id
    if (method === "DELETE" && path.startsWith("/tasks/")) {
      const id = path.split("/")[2];
      const existing = db.query("SELECT * FROM tasks WHERE id = ?").get(id);
      if (!existing) {
        return Response.json({ error: "Task not found" }, { status: 404 });
      }

      db.run("DELETE FROM tasks WHERE id = ?", [id]);
      return Response.json({ message: "Task deleted" });
    }

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

console.log(`Server running at http://localhost:${server.port}`);

I know what you're thinking — "where's Express? Where's Hono?" We don't need them. Bun's serve() gives us a web-standard Request/Response API. For a small API like this, a few if statements are cleaner than pulling in a routing framework.

Step 5: Run It

bash

bun run index.ts

You should see:

Server running at http://localhost:3000

That's it. No npx ts-node, no node --loader, no build step. Bun just runs your TypeScript directly.

Step 6: Test the Endpoints

Open another terminal and try these:

bash

# Create a task
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Bun"}'

# List all tasks
curl http://localhost:3000/tasks

# Get a specific task
curl http://localhost:3000/tasks/1

# Update a task
curl -X PUT http://localhost:3000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": 1}'

# Delete a task
curl -X DELETE http://localhost:3000/tasks/1

Every response comes back as JSON. The database persists to tasks.db in your project root, so your data survives restarts.

Step 7: Add a Quick Test

Bun has a built-in test runner too. Create a file called index.test.ts:

typescript

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

const BASE = "http://localhost:3000";

let taskId: number;

test("POST /tasks creates a task", async () => {
  const res = await fetch(`${BASE}/tasks`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: "Test task" }),
  });
  expect(res.status).toBe(201);
  const data = await res.json();
  expect(data.title).toBe("Test task");
  taskId = data.id;
});

test("GET /tasks returns an array", async () => {
  const res = await fetch(`${BASE}/tasks`);
  const data = await res.json();
  expect(Array.isArray(data)).toBe(true);
});

test("GET /tasks/:id returns the task", async () => {
  const res = await fetch(`${BASE}/tasks/${taskId}`);
  expect(res.status).toBe(200);
  const data = await res.json();
  expect(data.id).toBe(taskId);
});

test("DELETE /tasks/:id removes the task", async () => {
  const res = await fetch(`${BASE}/tasks/${taskId}`, { method: "DELETE" });
  expect(res.status).toBe(200);
});

Run the tests (make sure your server is running in another terminal):

bash

bun test

Clean, fast, no Jest config files.

Common Mistakes to Avoid

I've seen a few people trip up when moving from Node to Bun. Here are the ones that come up the most.

Don't install packages you don't need. Bun has SQLite, a test runner, environment variable loading (reads .env automatically), and a bundler built in. Before you bun add anything, check if Bun already handles it natively.

Don't assume Node.js compatibility issues. Bun is highly compatible with Node.js APIs, but there are edge cases. The node: prefix imports work, but some niche APIs in child_process or vm might behave slightly differently. Test your critical paths.

Don't forget about the Bun.serve() error handler. If your fetch handler throws an unhandled exception, the default behavior is to return a 500 with the error exposed. In production, add an error handler:

typescript

Bun.serve({
  port: 3000,
  fetch(req) {
    // your routes
  },
  error(err) {
    console.error(err);
    return new Response("Internal Server Error", { status: 500 });
  },
});

Don't use bun install and npm install in the same project. Pick one. Bun uses a binary lockfile (bun.lockb) that's different from package-lock.json. Mixing them leads to dependency confusion.

Where to Go From Here

You've got a working REST API in about 50 lines of actual logic. From here, there are a few natural next steps. You could add input validation with a library like Zod, swap SQLite for Postgres using bun:sql (experimental) or a driver like postgres, add JWT authentication, or deploy to a VPS where Bun runs as a single binary with no node_modules needed.

The Bun ecosystem is growing fast. Libraries like Hono and Elysia are built specifically for Bun and give you Express-like routing with type safety. But for small services and prototypes, the raw Bun.serve() approach we used here is hard to beat.

Wrapping Up

Bun takes the JavaScript runtime and strips away a lot of the accidental complexity we've gotten used to. No transpilation setup, no separate test runner config, no fighting with ESM versus CommonJS. You write TypeScript, you run it, and it's fast.

If you've been thinking about trying Bun, stop thinking and start building. This tutorial took you about ten minutes to follow — and now you have a working API with a database, tests, and zero configuration files.


Want to share what you've built? Join the community at CodeBrainery and write your own tutorials. Whether you're documenting a weekend project or breaking down a complex architecture, there's a reader out there who needs exactly what you know. Head over to codebrainery.com and start writing today.

If this post helped you, share it with a friend who's still running npm install and waiting.

Kodetra Technologies

Kodetra Technologies

Kodetra Technologies is a software development company that specializes in creating custom software solutions, mobile apps, and websites that help businesses achieve their goals.

0 followers

Comments

No comments yet. Be the first to comment!