In this chapter, you will create your first Bun project from scratch. By the end, you will have a running server that returns HTML in the browser, with watch mode for fast development and a clean Git history.
We will cover a lot of ground, but every step is small and every result is visible.
Create a New Project with bun init
Open your terminal and navigate to the learn-bun folder you created in Chapter 1:
$ cd learn-bun
Now create a new project:
$ bun init my-first-server
Bun will ask you to select a project template. Choose Blank and press Enter.
? Select a project template - Press return to submit.
❯ Blank
React
Library
Then move into the new folder:
$ cd my-first-server
That is the setup ritual. You will repeat it at the start of every project in this book: create a folder with bun init, move into it, start building.
Tour the Project Files
Bun created several files for you. Run ls to see them:
$ ls
You should see something like this:
index.ts
package.json
tsconfig.json
README.md
node_modules/
Here is what each file does:
index.ts — Your entry point. This is where your code starts. Bun created it with a simple console.log statement.
package.json — The project manifest. It holds the project name, any scripts you define, and your dependencies. Right now it is minimal.
tsconfig.json — TypeScript configuration for your editor. Bun uses this for autocomplete and type checking in VS Code. You do not need to change it.
node_modules/ — Bun ran bun install automatically and installed @types/bun, which gives your editor type information for Bun's built-in APIs. You will never edit files in this folder directly.
README.md — A placeholder readme. You can update this later.
You may also see a CLAUDE.md file or a .cursor/ folder. Bun creates these for AI coding tools. They are harmless and you can ignore them.
Run TypeScript Directly with bun run
Open index.ts in your editor. It should contain:
console.log("Hello via Bun!");
Run it:
$ bun run index.ts
You should see:
Hello via Bun!
That is bun run in action. It executes a TypeScript file directly — no compile step, no build tool. You give it a file, it runs the file.
Start a Simple Server with Bun.serve()
Now replace the contents of index.ts with a web server:
// index.ts
const server = Bun.serve({
port: 3000,
fetch(req) {
return new Response("Hello from Bun!");
},
});
console.log(`Server running at ${server.url}`);
Run it:
$ bun run index.ts
You should see:
Server running at http://localhost:3000/
Here is what this code does:
Bun.serve() starts an HTTP server. The port option tells it to listen on port 3000. The fetch function runs every time a request comes in and returns a Response. This is a standard Web API — the same Response you would use in a browser's fetch() call.
No framework. No middleware. Just a function that receives a request and returns a response.
Add Your First Route
Right now, every URL returns the same response. We can fix that using the routes option.
Stop the server with Ctrl + C, then update index.ts:
// index.ts
const server = Bun.serve({
port: 3000,
routes: {
"/": () => new Response("Home"),
},
fetch(req) {
return new Response("Page not found", { status: 404 });
},
});
console.log(`Server running at ${server.url}`);
Run it:
$ bun run index.ts
Open http://localhost:3000 in your browser. You should see "Home."
Now try http://localhost:3000/anything. You should see "Page not found." That is the fetch fallback in action — it handles any request that does not match a defined route and returns a 404 status so the browser knows the page was not found.
The routes object maps URL paths to handler functions. Each handler returns a Response. The fetch function catches everything else.
Add a Second Route
Now add an About page. Stop the server and update index.ts:
// index.ts
const server = Bun.serve({
port: 3000,
routes: {
"/": () => new Response("Home"),
"/about": () => new Response("About this site"),
},
fetch(req) {
return new Response("Page not found", { status: 404 });
},
});
console.log(`Server running at ${server.url}`);
Run it again:
$ bun run index.ts
Visit http://localhost:3000/about. You should see "About this site."
Adding a route is one line. The pattern is always the same: a path string, an arrow function, a Response. You will add many more routes throughout this book, and it will always look like this.
Return HTML from Bun
Plain text is fine for testing, but real pages need HTML.
Stop the server and update the home route in index.ts:
// index.ts
const server = Bun.serve({
port: 3000,
routes: {
"/": () =>
new Response(
`<!DOCTYPE html>
<html>
<head><title>My Bun App</title></head>
<body>
<h1>Welcome</h1>
<p>This page is served by Bun.</p>
</body>
</html>`,
{
headers: { "Content-Type": "text/html" },
}
),
"/about": () => new Response("About this site"),
},
fetch(req) {
return new Response("Page not found", { status: 404 });
},
});
console.log(`Server running at ${server.url}`);
Run it:
$ bun run index.ts
Open http://localhost:3000. You should see a rendered page with "Welcome" as the heading instead of raw text.
The key change is the Content-Type header. Without it, the browser treats the response as plain text and shows the raw HTML tags. Setting it to text/html tells the browser to render the HTML as a page.
Now update the /about route the same way:
// index.ts
const server = Bun.serve({
port: 3000,
routes: {
"/": () =>
new Response(
`<!DOCTYPE html>
<html>
<head><title>My Bun App</title></head>
<body>
<h1>Welcome</h1>
<p>This page is served by Bun.</p>
<a href="/about">About</a>
</body>
</html>`,
{
headers: { "Content-Type": "text/html" },
}
),
"/about": () =>
new Response(
`<!DOCTYPE html>
<html>
<head><title>About</title></head>
<body>
<h1>About</h1>
<p>A simple site built with Bun.</p>
<a href="/">Home</a>
</body>
</html>`,
{
headers: { "Content-Type": "text/html" },
}
),
},
fetch(req) {
return new Response("Page not found", { status: 404 });
},
});
console.log(`Server running at ${server.url}`);
Run the server:
$ bun run index.ts
View It in the Browser
Open your browser and go to:
http://localhost:3000
You should see a page with "Welcome" as the heading and a link to the About page. Click the link. You should see the About page with a link back to Home.
You built a multi-page site with nothing but Bun.
Now try a URL that does not exist, like http://localhost:3000/nothing. You should see "Page not found" — that is your fetch fallback in action.
Stop and Restart the Server
While the server is running, your terminal is occupied. You cannot type new commands.
To stop the server, press:
Ctrl + C
You will see the terminal return to the normal prompt. The server is no longer running, and http://localhost:3000 will stop responding.
To start it again:
$ bun run index.ts
You will do this cycle constantly during development: edit code, stop the server, restart the server, check the browser. The next section makes this faster.
Use Watch Mode for Faster Development
Stopping and restarting the server after every change gets tedious. Bun has a built-in solution: watch mode.
Stop the server, then start it with the --watch flag:
$ bun run --watch index.ts
Now make a small change — edit the heading in the home route from "Welcome" to "Welcome to Bun" and save the file. Bun detects the change and restarts the server automatically. Refresh your browser and you will see the updated text.
Watch mode monitors all imported files. When any of them change, Bun restarts the process. This eliminates the stop-restart cycle entirely.
You will use --watch for the rest of this book.
Add a Script to package.json
Open package.json in your editor. Bun generated this file when you ran bun init:
{
"name": "my-first-server",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}
Here is what each field does:
name — The project name. Bun set this to the folder name.
module — The entry point for your project. Bun pointed this at the index.ts file it created.
type — Set to "module" so JavaScript files use modern import/export syntax by default.
private — Set to true so you do not accidentally publish this project to npm.
devDependencies — Packages needed during development. Bun installed @types/bun here so your editor gets type information for Bun's APIs.
peerDependencies — Declares that this project expects TypeScript 5 to be available. Bun handles TypeScript natively, so you do not need to install it separately.
Now add a scripts section so you can start the server with a shorter command:
{
"name": "my-first-server",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun run --watch index.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}
Now you can start the server with:
$ bun run dev
This runs the dev script from package.json, which runs bun run --watch index.ts. It is the same command, just easier to remember and type.
We use dev as the script name because it is the conventional name for local development. You will see this pattern in almost every JavaScript project.
Save Your Work with Git
This is a good stopping point. We have a working project with routes, HTML, and watch mode. Time to save it.
Initialize a Git repository:
$ git init
Bun already created a .gitignore file that excludes node_modules/. Check the status to see what will be committed:
$ git status
You should see your project files listed as untracked. Add them all:
$ git add .
Then commit:
$ git commit -m "Initial commit: first Bun server with routes and HTML"
You now have a saved snapshot of your working project. If anything breaks later, you can always come back to this point.
We will commit at the end of every chapter. This builds the habit of saving progress regularly and gives you a safety net throughout the book.
Conclusion
You created a Bun project from scratch, ran a script, built a server with multiple routes, returned real HTML, and set up watch mode for fast development. You also saved your work with Git.
The key ideas from this chapter:
bun init scaffolds a project. bun run executes a file. Bun.serve() starts a server. The routes object maps paths to handlers. The fetch function catches everything else. Watch mode keeps the server in sync with your code.
This chapter covered the first of Bun's four pillars: the runtime. bun run executes your TypeScript directly, with no compile step in between.
In the next chapter, we will step away from the server and build a small command-line tool. Along the way, you will install your first package with bun add — that is the second pillar, the package manager.
Get notified when Part II ships.
One email per release. No spam, no upsells. Just the next set of chapters when they're ready.