Building Your First Fullstack App With Bun: A Self-Taught Dev's Walkthrough
When I first heard about Bun a couple of years back, I was skeptical. The JavaScript ecosystem doesn’t really need another runtime, does it? But here we are in 2026, and Bun has quietly become my default choice for new projects. It’s fast, the developer experience is genuinely nice, and most of the stuff that made it feel risky early on has been smoothed out.
This walkthrough is the kind of thing I wish I’d had when I started experimenting with Bun. We’ll build a small fullstack app—a habit tracker, because I’m tired of building todo apps—using Bun for both the backend and the build tooling, with React on the frontend.
I’ll be honest about the parts where I got stuck. Self-taught devs benefit more from seeing the mistakes than from seeing polished code.
Why Bun Instead of Node
Quick context for why this is even worth doing. Bun is a JavaScript runtime built on JavaScriptCore (the Safari engine) instead of V8 (Chrome/Node). It includes a bundler, package manager, and test runner built in. Where Node needs npm, webpack or vite, and jest or vitest to do everything you need, Bun does it all in one binary.
The practical benefits I’ve noticed:
Install speed is genuinely faster than npm. A fresh install of a moderately complex project that takes 35 seconds with npm takes about 8 seconds with bun install. Over a development cycle, this adds up.
Startup time for scripts is dramatically faster. My local dev server takes about half a second to start with Bun, versus 2-3 seconds with Node and a typical build setup.
The built-in TypeScript support means no separate build step for simple projects. You write TypeScript, you run it directly.
There are still rough edges, particularly around some Node ecosystem packages that use less common APIs. But for new projects, I rarely hit them now.
Setting Up
If you don’t have Bun installed, grab it from bun.sh. On macOS and Linux, the curl install is the easiest path. On Windows, the WSL setup is most reliable, though native Windows support has improved.
Once installed, create a new project:
mkdir habit-tracker
cd habit-tracker
bun init
Bun’s init asks a few questions and produces a project skeleton. For our purposes, accept the defaults.
The first thing I want to change is the project structure. The default puts everything at the root, which gets messy as the project grows. I’ll create separate directories for the server and client:
mkdir -p src/server src/client
Building the Backend
Bun has a built-in HTTP server that’s both simpler and faster than Express or Fastify for many use cases. Here’s the starting point for our server, in src/server/index.ts:
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/api/habits") {
return Response.json({ habits: [] });
}
return new Response("Not found", { status: 404 });
},
});
console.log(`Server running on http://localhost:${server.port}`);
Run it with bun run src/server/index.ts and you’ve got a server. No express install, no middleware setup, no boilerplate.
For data persistence, Bun ships with built-in SQLite support that’s actually fast. This was the moment I started taking Bun seriously. No separate driver, no async wrapper—just a sensible synchronous API.
import { Database } from "bun:sqlite";
const db = new Database("habits.sqlite");
db.run(`
CREATE TABLE IF NOT EXISTS habits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS completions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
habit_id INTEGER NOT NULL,
completed_date TEXT NOT NULL,
FOREIGN KEY (habit_id) REFERENCES habits(id)
)
`);
export { db };
The first time I tried this, I made a mistake that took me an embarrassing amount of time to find. I had import { Database } from "sqlite" instead of bun:sqlite. The error message was unhelpful. Lesson learned: Bun’s built-in modules use the bun: prefix.
Now we can expand the server to actually do something:
import { db } from "./db";
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/api/habits" && req.method === "GET") {
const habits = db.query("SELECT * FROM habits").all();
return Response.json({ habits });
}
if (url.pathname === "/api/habits" && req.method === "POST") {
const body = await req.json();
const result = db.run(
"INSERT INTO habits (name) VALUES (?)",
[body.name]
);
return Response.json({ id: result.lastInsertRowid });
}
return new Response("Not found", { status: 404 });
},
});
For a real app you’d want input validation, error handling, and probably a routing library. For a learning exercise, this is enough to get going.
Adding the React Frontend
Bun can serve static files and bundle JavaScript directly. For React, the setup is more involved than the server, but still simpler than a typical Node setup.
Install React:
bun add react react-dom
bun add -d @types/react @types/react-dom
Create src/client/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Habit Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
And src/client/index.tsx:
import { createRoot } from "react-dom/client";
import { useState, useEffect } from "react";
function App() {
const [habits, setHabits] = useState([]);
useEffect(() => {
fetch("/api/habits")
.then(r => r.json())
.then(data => setHabits(data.habits));
}, []);
return (
<div>
<h1>My Habits</h1>
<ul>
{habits.map(h => <li key={h.id}>{h.name}</li>)}
</ul>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);
This is where I hit my second snag. Serving the HTML and bundling the TSX together took some trial and error. The cleanest pattern I found is to use Bun’s HTML imports:
import index from "./client/index.html";
const server = Bun.serve({
port: 3000,
static: {
"/": index,
},
async fetch(req) {
// API routes here
},
});
This works because Bun treats HTML imports specially and handles the bundling automatically.
What I’d Do Differently Next Time
Three things I’d change if I were starting over.
First, I’d commit to TypeScript more aggressively earlier. I kept some files as plain JavaScript out of habit and regretted it when refactoring became harder than it needed to be.
Second, I’d add proper error handling from the start. The simple return new Response("Not found", { status: 404 }) is fine for learning but becomes a debugging nightmare even at small scale.
Third, I’d think about data validation earlier. I added Zod schemas after I had a working prototype, and migrating to them was more work than including them from the start would have been.
What’s Next
This walkthrough covered the foundation. From here, you can build out the habit completion tracking, add a calendar view, implement streaks, and so on. Most of what you’d add follows the same pattern—database query, API route, React component.
If you’re curious about deploying Bun apps, Railway and Fly.io both have decent Bun support now. I deployed this habit tracker to Fly with maybe four config tweaks.
Self-taught dev advice that’s broader than this tutorial: don’t pick tools based on what’s trendy. Pick them based on whether you can debug what they’re doing under the hood when something breaks. Bun makes the cut for me because the surface area is small enough that I can actually understand what it’s doing. The same isn’t true of every framework or runtime I’ve tried over the years, and that’s been a useful filter.