Building Full-Stack Apps with Bun: A Developer’s Honest Take
Look, I was skeptical at first. Like, really skeptical. I’d been using Node.js for over five years built everything from small side projects to production apps handling thousands of requests. Node.js worked fine. It wasn’t broken. So when I heard about Bun, my first thought was: “Why should I care? Another JavaScript runtime? Come on.”
But then something annoyed me enough to actually try it. One Tuesday morning, I was setting up a new full-stack project and I realized I had to install and configure like six different tools just to get started. Node.js for the backend, npm, Webpack for the frontend, Jest for testing, ESLint for linting… it felt bloated. My coffee got cold while I was waiting for my dev server to start.
That’s when I decided to actually test out this Bun thing everyone was talking about. And honestly? It changed how I think about building web apps. I’m not here to sell you on Bun. I’m just going to tell you what happened when I used it for a full-stack project, what my friends experienced, and whether it’s actually worth your time.
The Problem That Nobody Talks About
You know what’s boring? Node.js boilerplate. Every new project, same story: initialize npm, install dependencies, set up a bundler, configure the bundler, fight with webpack config for two hours, finally get hot reloading working.
My friend Rahul we work together at a startup he told me something that stuck with me. He said: “I spend more time configuring tools than actually writing code. Isn’t that backwards?” He was right. And I wasn’t alone. Most developers I know feel this way but just accept it as the cost of doing business.
Then there’s the mental load. You’ve got to keep track of what Node.js version everyone’s using, make sure your build tool is configured right, remember that your database client needs a specific import in the frontend vs backend… it’s a lot of little things that add up to friction. The real issue? We have a great language (JavaScript) but a fragmented ecosystem where every tool does its own thing.
What Bun Actually Is (And What It Isn’t)
Bun is basically a new JavaScript runtime think of it as an alternative to Node.js. It’s built with Zig, which is a systems programming language, which makes it stupidly fast.
But here’s the thing that makes it interesting for full-stack work: it doesn’t just run JavaScript. It comes with package management, a bundler, and a test runner built in. One tool instead of five.
I installed it on a Tuesday afternoon, and by Wednesday I had my first app running. That’s not exaggeration.
The honest part? Bun’s still younger than Node.js. Not everything on npm works with it yet. If you depend on some weird niche library, you might hit a wall. But if you’re using normal, popular packages? You’re probably fine.
Getting Started (It’s Seriously Fast)
Install it. On Mac or Linux:
curl -fsSL https://bun.sh/install | bash
Windows:
powershell -c "irm bun.sh/install.ps1 | iex"
Check it worked:
bun --version
Now create a new project:
mkdir my-app
cd my-app
bun init
That’s it. You’ve got a package.json and you’re ready to go.
Here’s how I organized my project. This structure just makes sense to me:
my-app/
├── backend/
│ ├── src/
│ │ ├── index.ts # Server entry point
│ │ ├── routes.ts # Route handlers
│ │ └── db.ts # Database stuff
│ └── tests/
├── frontend/
│ ├── src/
│ │ ├── App.tsx
│ │ ├── components/
│ │ └── pages/
│ ├── public/
│ └── tests/
├── shared/
│ └── types.ts # Types both sides use
├── package.json
└── bunfig.toml
The shared folder is where the magic happens. Put your TypeScript types there, both frontend and backend import from it, and suddenly your API is self-documenting.
Building the Backend (It Feels Lightweight)
Here’s what surprised me most: building an HTTP server in Bun is genuinely simple. No heavy framework, no middleware chains, just:
import { serve } from "bun";
const server = serve({
port: 3000,
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/hello") {
return new Response(JSON.stringify({ msg: "hi" }), {
headers: { "Content-Type": "application/json" }
});
}
return new Response("not found", { status: 404 });
},
});
console.log(`running on http://localhost:${server.port}`);
Run it: bun backend/src/index.ts
Done. You have a working API.
Now, for something more real, I built a simple router because the above won’t scale:
export class Router {
private routes = new Map();
get(path, handler) {
this.routes.set(`GET:${path}`, handler);
}
post(path, handler) {
this.routes.set(`POST:${path}`, handler);
}
async handle(request) {
const url = new URL(request.url);
const key = `${request.method}:${url.pathname}`;
const handler = this.routes.get(key);
if (!handler) {
return new Response("not found", { status: 404 });
}
try {
return await handler(request);
} catch (err) {
console.error(err);
return new Response("something broke", { status: 500 });
}
}
}
Nothing fancy, just enough to handle multiple routes. You can use Express if you want Bun works with it but honestly I didn’t need it for my projects.
Actually Talking to a Database
I use Postgres. Bun has good support for it:
bun add postgres
Then in backend/src/db.ts:
import { sql } from "postgres";
const client = sql({
hostname: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
username: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWORD || "postgres",
database: process.env.DB_NAME || "myapp",
});
export default client;
And you can just do stuff like:
export async function getTodos(userId) {
return await db`SELECT * FROM todos WHERE user_id = ${userId}`;
}
export async function createTodo(userId, title) {
const [todo] = await db`
INSERT INTO todos (user_id, title)
VALUES (${userId}, ${title})
RETURNING *
`;
return todo;
}
It’s clean. No ORM boilerplate unless you want it.
Protecting Your Routes
You can’t just let anyone call your API. I use JWT tokens:
bun add jsonwebtoken
In backend/src/middleware/auth.ts:
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET || "dev-secret";
export function makeToken(userId) {
return jwt.sign({ userId }, SECRET, { expiresIn: "7d" });
}
export function checkToken(token) {
try {
return jwt.verify(token, SECRET);
} catch {
return null;
}
}
export async function needAuth(request) {
const header = request.headers.get("Authorization");
if (!header?.startsWith("Bearer ")) {
return null;
}
const token = header.slice(7);
return checkToken(token);
}
Use it like:
router.get("/api/todos", async (request) => {
const user = await needAuth(request);
if (!user) {
return new Response("not authorized", { status: 401 });
}
return getTodos(user.userId);
});
The Frontend Side
React works fine with Bun. Install it:
bun add react react-dom
bun add -d @types/react @types/react-dom
In frontend/src/App.tsx:
import React, { useState, useEffect } from "react";
export default function App() {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchTodos();
}, []);
async function fetchTodos() {
try {
const res = await fetch("/api/todos", {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const data = await res.json();
setTodos(data);
} catch (err) {
console.error("couldn't fetch", err);
} finally {
setLoading(false);
}
}
async function addTodo(title) {
const res = await fetch("/api/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ title }),
});
const newTodo = await res.json();
setTodos([...todos, newTodo]);
}
if (loading) return <div>loading...</div>;
return (
<div>
<h1>todo app</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button onClick={() => addTodo("new task")}>add</button>
</div>
);
}
Bundle it:
bun build frontend/src/App.tsx --outdir frontend/dist
Bun handles the JSX automatically. No Webpack drama.
The Type Safety Thing (This Is Actually Nice)
Here’s where it gets good. Put your shared types in shared/types.ts:
export interface TodoItem {
id: string;
userId: string;
title: string;
completed: boolean;
createdAt: Date;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
Your backend returns this type:
export async function getTodos(userId): Promise<TodoItem[]> {
return await db`SELECT * FROM todos WHERE user_id = ${userId}`;
}
Your frontend gets it with the right types already:
const todos: TodoItem[] = await response.json();
// TypeScript knows what fields exist
If someone changes the type but forgets to update the code that uses it? TypeScript yells at you immediately. This catches bugs before they hit production.
Connecting Everything Together
Run the backend:
bun backend/src/index.ts
Build the frontend:
bun build --watch frontend/src/App.tsx --outdir frontend/dist
Create a simple index.html in your public folder to serve the built app, and boom you’re running a full-stack app with one runtime.
I put this in package.json:
{
"scripts": {
"api": "bun backend/src/index.ts",
"web": "bun build --watch frontend/src/App.tsx --outdir frontend/dist",
"dev": "concurrently \"bun api\" \"bun web\""
}
}
Install concurrently:
bun add -d concurrently
Then run both with: bun dev
What My Friend Rahul Experienced
Remember Rahul? The one frustrated with configuration? He actually switched his side project to Bun and told me about it over coffee last month.
His words: “Setup took me 10 minutes instead of an hour. That’s 50 minutes of my life I got back. The weird part? I didn’t miss anything. No mystery build errors. Things just work.”
He mentioned one issue: one of his libraries didn’t work with Bun out of the box. But he found a different library that did the same thing and moved on. No big deal.
What surprised him most was the speed. His local dev server starts in like 50 milliseconds. He said it actually feels responsive.
My other friend Priya also switched a project. She was skeptical like me. Her feedback: “The TypeScript support is genuine. Errors catch before runtime. That’s worth it alone.”
She did hit one snag with a database driver, but the Bun team responded to her issue on GitHub within a few hours. The community is actually helpful.
Testing Stuff (You’ll Need This)
Bun has a built-in test runner. No Jest, no Mocha setup:
import { describe, it, expect } from "bun:test";
import { getTodos, createTodo } from "../src/db";
describe("todo stuff", () => {
it("creates a todo", async () => {
const todo = await createTodo("user-1", "test task");
expect(todo.title).toBe("test task");
});
it("gets todos", async () => {
const todos = await getTodos("user-1");
expect(Array.isArray(todos)).toBe(true);
});
});
Run tests:
bun test
That’s it. No configuration needed.
Going to Production
When I was ready to ship, I had a few options:
Vercel: They support Bun. You just push your code and it works.
Railway: Same thing. Connect your GitHub, it deploys.
Your own server: Make a Docker image:
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 3000
CMD ["bun", "backend/src/index.ts"]
Or compile to a single binary:
bun build backend/src/index.ts --compile --outfile myapp
Then just run ./myapp on your server. No Bun needed, no Node.js needed. For my projects, I went with Vercel because it was stupidly easy. Pushed code, it deployed. Done.
Honest Problems I Actually Hit
It’s not perfect. Here’s what annoyed me:
Package compatibility: Not every npm package works yet. I needed a date library and the one I normally use didn’t work. But there are alternatives everywhere, so it’s more of an annoyance than a blocker.
Documentation: Bun’s docs are decent but not as comprehensive as Node.js docs. Sometimes I had to dig into examples or GitHub issues. But it’s improving fast.
Community size: Node.js has millions of developers. Bun has thousands. This means fewer Stack Overflow answers, fewer tutorials. But the community is helpful and responsive.
Debugging: Dev tools exist but feel newer. I had to use Chrome DevTools instead of my usual setup. Worked fine, just different.
None of these are deal-breakers. They’re just… things.
Should You Actually Use This?
Honest answer? It depends.
Try Bun if:
- You’re starting a new project and want less configuration headache
- You’re tired of managing multiple tools
- You want faster startup and build times
- You like the idea of TypeScript everywhere
Stick with Node.js if:
- You depend on weird niche packages that only work with Node.js
- Your team is already comfortable with Node.js and doesn’t want to learn something new
- You’re migrating a massive legacy codebase (not worth the risk)
- You need extreme backwards compatibility
For me? I use Bun for new projects. It’s genuinely faster and the developer experience is better. My team got up to speed in like a day. The type safety between frontend and backend alone is worth it.
Some Real Stuff I Noticed
Building the same todo app in Node.js vs Bun, here’s what changed:
- Setup time: 1 hour with Node.js → 15 minutes with Bun
- Dev server startup: ~300ms with Node.js → ~50ms with Bun
- Build time for frontend: ~3 seconds with Webpack → ~1 second with Bun
- Finding bugs: More caught at compile time with Bun’s stricter TypeScript
These aren’t massive differences individually, but they compound. Over a week of development, that’s real time saved.
Getting Help
The Bun community exists and is welcoming:
- Docs: https://bun.sh/docs (actually decent)
- Discord: https://bun.sh/discord (real people, fast responses)
- GitHub: https://github.com/oven-sh/bun (they read issues and PRs)
I’ve asked questions in the Discord and got answered by actual developers within minutes. It’s nice.
Final Thoughts
I’m not going to sit here and tell you Bun is the future and everyone should use it. That’s marketing talk. What I will say is: I tried it, I liked it, my friends tried it and liked it, and now I use it for new projects. It makes building full-stack apps less annoying. That’s worth something.
If you’ve got a side project sitting in a folder somewhere that you keep meaning to work on, try building it with Bun. Spend a weekend on it. See what you think. You can always go back to Node.js if it doesn’t click.
Or if you’re starting something new anyway, why not? Worst case, you learn a new tool. Best case, you find something that makes you enjoy building web apps a little bit more. That’s really all I’m saying.
