b
LearnBun.org
Chapter 05 · Routes, Layout, and Static Files
Chapter 5

Routes, Layout, and Static Files

In Chapter 4, you built a server with two routes that returned hardcoded HTML embedded inside index.ts. That was enough to see Bun work, but a real app needs more: HTML in real files, a shared layout, static assets like CSS, and a clean way to serve them all.

This chapter delivers that foundation. By the end, you will have a small site with a layout that wraps every page, a CSS file that styles them, a health check route, a single wildcard route that serves any file from a public/ folder, and a small set of tests that pin down each route's behavior. We will add forms and state in Chapter 6.

We will cover a lot of ground, but every step is small and every result is visible.

Where We Left Off

Open the project from Chapter 2 and start the server:

$ cd learn-bun/my-first-server
$ bun run dev

Visit http://localhost:3000. You should see the welcome page with a link to About. If something is broken, run git status to confirm you have a clean working directory from your Chapter 2 commit.

Here is what we are building toward in this chapter:

  • HTML in real .html files instead of embedded strings
  • A shared layout that wraps every page with the same header and navigation
  • A health check route at /ok
  • Stylesheet at /style.css served from a public/ folder
  • Tests that confirm every route works

We will get there step by step.

Move HTML into Files

Before we add anything new, let us clean up what we have.

In Chapter 2, we put HTML directly inside index.ts as template strings. That was fine for two short pages, but it has a few problems:

  • Editing HTML inside a TypeScript string is awkward — no syntax highlighting, no auto-complete, no formatting.
  • Each route handler is buried under fifteen lines of markup.
  • Adding a third or fourth page would make the file hard to read.

The fix is simple: put the HTML in real .html files and serve them directly.

Stop the server with Ctrl + C, then open the project in VS Code:

$ code .

Create a new file called home.html in the project root:

<!-- home.html -->
<!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>

Then create about.html:

<!-- about.html -->
<!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>

Now update index.ts to serve these files instead of inline strings:

// index.ts
const server = Bun.serve({
  port: 3000,
  routes: {
    "/": () =>
      new Response(Bun.file("./home.html"), {
        headers: { "Content-Type": "text/html; charset=utf-8" },
      }),
    "/about": () =>
      new Response(Bun.file("./about.html"), {
        headers: { "Content-Type": "text/html; charset=utf-8" },
      }),
  },
  fetch(req) {
    return new Response("Page not found", { status: 404 });
  },
});

console.log(`Server running at ${server.url}`);

Run the server:

$ bun run dev

Visit http://localhost:3000 and http://localhost:3000/about. The pages look exactly the same as before. Nothing has changed for the user — but the code is much shorter and the HTML lives where you can edit it like normal HTML.

Here is what is new:

Bun.file("./home.html") returns a reference to a file on disk. It does not read the file yet — that happens when the response is sent. When you wrap it in a Response, Bun streams the file contents efficiently to the browser.

We still set the Content-Type header. The browser needs to know it is HTML, and charset=utf-8 tells it to interpret the bytes as UTF-8 — the encoding used by every modern editor and operating system. Without that, characters outside basic ASCII (accented letters, em dashes, emoji, anything from a non-English alphabet) can render as garbage. Setting the charset on every HTML response is a one-time habit that prevents an entire class of encoding bugs.

You may have noticed that both files start with the same <!DOCTYPE html>, <html>, <head>, and <body> boilerplate. That duplication is real, and we will fix it later in this chapter when we add a shared HTML helper. For now, two short files are easier to work with than two long template strings.

Add a Health Check

Now we add a different kind of route — one that does not return HTML at all.

A health check is a small endpoint that returns a quick response to confirm the server is running. Monitoring tools, load balancers, and deployment platforms hit a known URL to check if the app is alive. If the route responds, the server is up. If it does not, something is wrong and the platform can take action.

We will need this in Chapter 15 when we deploy. Adding it now also gives us a chance to learn something new about Bun routes.

Update index.ts:

// index.ts
const server = Bun.serve({
  port: 3000,
  routes: {
    "/": () =>
      new Response(Bun.file("./home.html"), {
        headers: { "Content-Type": "text/html; charset=utf-8" },
      }),
    "/about": () =>
      new Response(Bun.file("./about.html"), {
        headers: { "Content-Type": "text/html; charset=utf-8" },
      }),
    "/ok": new Response("OK"),
  },
  fetch(req) {
    return new Response("Page not found", { status: 404 });
  },
});

console.log(`Server running at ${server.url}`);

Save the file. Watch mode restarts the server.

Visit http://localhost:3000/ok. You should see "OK".

Notice this route is different from the others. There is no arrow function — just a Response object directly. Bun treats this as a static response and caches it at startup. Every request to /ok returns the same cached response with no allocation and no handler call.

Static responses are perfect for health checks, fixed redirects, and anything that does not change per request.

Create an HTML Helper

The pages we are about to build need to share a layout. Every page gets the same <head> setup, the same navigation bar, the same stylesheet link — only the title and main content change.

We could write each page as its own .html file with the boilerplate repeated. But then changing the navigation means editing every file. We need one place to define the layout, and a way to drop different content into it for each page.

This is what templating is for. The simplest approach: an HTML file with placeholders, and a small function that fills them in.

Create layout.html in the project root:

<!-- layout.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>{{title}}</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <nav>
      <a href="/">Home</a> |
      <a href="/about">About</a>
    </nav>
    {{body}}
  </body>
</html>

The {{title}} and {{body}} markers are placeholders. They have no special meaning to HTML or to Bun — they are just text. Our code will look for them and replace them with real values.

Now add a page() function to index.ts:

// index.ts (partial)
const layout = await Bun.file("./layout.html").text();

function page(title: string, body: string): Response {
  const html = layout
    .replaceAll("{{title}}", title)
    .replaceAll("{{body}}", body);
  return new Response(html, {
    headers: { "Content-Type": "text/html; charset=utf-8" },
  });
}

Two things are worth pausing on.

First, layout is loaded once, at module load, not on every request. The line runs when index.ts is first executed. After that, the layout sits in memory as a string. Every request just calls replaceAll on that string, which is fast — no disk read, no file open, no parse. This is how every templating engine works under the hood: read the template once, render it many times.

Second, this works because Bun supports top-level await. In older Node setups, you cannot await outside of an async function — you would have to wrap the load in a function call or use .then(). Bun handles this natively, so the load reads cleanly at the top of the file.

The function itself is straightforward: take the cached layout, replace {{title}} with the actual title, replace {{body}} with the actual body, return the result as a Response. We use replaceAll instead of replace so that if a placeholder appears more than once, every occurrence gets substituted.

Now rewrite the home and about routes to use page():

// index.ts (partial)
const server = Bun.serve({
  port: 3000,
  routes: {
    "/": () =>
      page("My Bun App", `<h1>Welcome</h1><p>This page is served by Bun.</p>`),
    "/about": () =>
      page("About", `<h1>About</h1><p>A simple site built with Bun.</p>`),
    "/ok": new Response("OK"),
  },
  fetch(req) {
    return new Response("Page not found", { status: 404 });
  },
});

Save the file and refresh the home page.

The page now has a navigation bar at the top with three links. The HTML structure is consistent. Each route handler is one line. Editing the navigation in layout.html will change every page in the app.

The home.html and about.html files we created earlier are no longer used. You can delete them:

$ rm home.html about.html

The link to /style.css does not work yet — there is no stylesheet to load. We will fix that next.

Serve Static Files and Add CSS

The app needs CSS. We could embed styles in the HTML helper, but real apps serve CSS, images, and JavaScript from separate files. Let us do it the right way from the start.

Create a public/ folder in your project:

$ mkdir public

Then create public/style.css:

/* public/style.css (entire file) */
body {
  font-family: system-ui, sans-serif;
  max-width: 640px;
  margin: 2rem auto;
  padding: 0 1rem;
  line-height: 1.5;
  color: #222;
}

nav {
  margin-bottom: 2rem;
  padding-bottom: 1rem;
  border-bottom: 1px solid #ddd;
}

nav a {
  margin-right: 0.5rem;
}

h1 {
  margin-bottom: 1rem;
}

form {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

input,
textarea,
button {
  font: inherit;
  padding: 0.5rem;
}

Now we need a route that serves files from the public/ folder. Add a wildcard route to index.ts:

// index.ts (partial)
const server = Bun.serve({
  port: 3000,
  routes: {
    "/": () =>
      page("My Bun App", `<h1>Welcome</h1><p>This page is served by Bun.</p>`),
    "/about": () =>
      page("About", `<h1>About</h1><p>A simple site built with Bun.</p>`),
    "/ok": new Response("OK"),
    "/*": async (req) => {
      const url = new URL(req.url);
      const file = Bun.file(`./public${url.pathname}`);
      if (await file.exists()) {
        return new Response(file);
      }
      return new Response("Page not found", { status: 404 });
    },
  },
  fetch(req) {
    return new Response("Page not found", { status: 404 });
  },
});

Save the file and refresh the home page. The page should now have a clean layout, system font, and a clear navigation bar.

Here is what changed:

/* is a wildcard route. It matches any URL that no other route matches. Bun checks more specific routes first — /, /about, /ok — and only falls through to /* when none of them match.

Inside the handler, we parse the URL and use Bun.file() to load the matching file from the public/ folder. Bun.file() does not check whether the file exists — it just creates a reference to a path. So we ask explicitly with await file.exists(). If the file is there, we return it as a Response and Bun streams it to the browser. If it is not, we return a 404 with "Page not found." That last branch is what catches URLs like /missing-page — there is no ./public/missing-page file, so we return the 404 ourselves.

The handler is async because file.exists() is asynchronous. Bun's route handlers can be async, and Bun awaits them automatically.

So /style.css looks for ./public/style.css. A future /logo.png would look for ./public/logo.png. One route handles every static file.

See It All Working

Walk through what we built:

  1. Visit http://localhost:3000. You should see the home page wrapped in the layout, with the navigation bar and the welcome content.
  2. Click "About" in the navigation. You should see the About page with the same layout.
  3. Visit http://localhost:3000/ok. You should see "OK".
  4. Visit http://localhost:3000/style.css. You should see your CSS.
  5. Visit http://localhost:3000/missing-page. You should see "Page not found."

Every piece is working together: file-based HTML, the layout helper, the static response, the wildcard route serving CSS, and the 404 fallback.

Test What You Built

You clicked through five URLs and saw them all work. That is encouraging, but it is five data points captured by hand. The next chapter adds forms, POST handlers, and a notes array, and index.ts is going to grow. We need a way to confirm the routes still work after every change — without clicking through five URLs every time.

This is the same problem we hit in Chapter 4 with countWords. The solution is the same: write tests, run them with bun test, let the runner check the behavior for us.

The pattern is a little different here because we are testing a server instead of a pure function. Bun makes this easy. A Bun.serve() instance has a .fetch() method that takes a Request and returns a Response directly — no network, no port, no waiting. We can hit our routes from inside a test the same way the runtime hits them when a real browser makes a request.

Guard the server start

Before we can test anything, we have to deal with a side effect. Right now, index.ts calls Bun.serve() at the top level and prints a message. That is exactly what we want when we run bun run dev. It is exactly what we do not want when bun test imports the file.

This is the same lesson we learned in Chapter 4. Importing index.ts from a test runs every top-level statement in the file. In Chapter 4 that meant the script's Bun.argv check fired during tests. Here it means the server starts and a "Server running at..." message appears every time bun test runs — and if bun run dev is also running in another terminal, the test would fail with a port-in-use error.

The fix is to separate the parts of the file that must run in dev from the parts that should run on import. We need two things from index.ts:

  • The server itself running on port 3000, so the test file's fetch() calls have something to hit
  • The dev-only side effects (the log line) gated behind a check so they do not fire during tests

Bun gives us a property for exactly this kind of check: import.meta.main. It is true when the current file is the one passed to bun run, and false when another file imports it.

Stop the server with Ctrl + C and update the bottom of index.ts:

// index.ts
...
export const server = Bun.serve({
  port: 3000,
  routes: {
    "/": () =>
      page("My Bun App", `<h1>Welcome</h1><p>This page is served by Bun.</p>`),
    "/about": () =>
      page("About", `<h1>About</h1><p>A simple site built with Bun.</p>`),
    "/ok": new Response("OK"),
    "/*": async (req) => {
      const url = new URL(req.url);
      const file = Bun.file(`./public${url.pathname}`);
      if (await file.exists()) {
        return new Response(file);
      }
      return new Response("Page not found", { status: 404 });
    },
  },
  fetch(req) {
    return new Response("Page not found", { status: 404 });
  },
});

if (import.meta.main) {
  console.log(`Server running at ${server.url}`);
}

Two changes. We exported server so the test file can import it. And we wrapped the console.log in an import.meta.main check so the dev-mode message stays out of the test output.

Run the server to make sure nothing broke:

$ bun run dev

Visit http://localhost:3000. Same page as before. Stop the server again with Ctrl + C.

Write your first test

Create index.test.ts next to index.ts:

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

test("home page returns 200 with welcome text", async () => {
  const res = await fetch("http://localhost:3000/");
  expect(res.status).toBe(200);
  const html = await res.text();
  expect(html).toContain("Welcome");
});

Three pieces here, like Chapter 4. test and expect come from bun:test. The side-effect import — import "./index.ts" — runs the file for its side effects (starting the server) without pulling any specific names out of it. Then the test uses the global fetch() to send a real HTTP request to the running server.

One thing worth noticing: the dev server can not be running while the tests run. If you have bun run dev going in another terminal, the test process tries to start its own server on port 3000 and fails immediately with EADDRINUSE. Stop the dev server before running bun test. We will look at random ports as a cleaner answer in Book 2.

Run the test:

$ bun test

You should see something like:

wordcount.test.ts:
✓ countWords > counts two words [0.45ms]
...

index.test.ts:
✓ home page returns 200 with welcome text [2.31ms]

 7 pass
 0 fail

The wordcount tests from Chapter 4 still run too. bun test discovers every *.test.ts file in the project.

See it fail

Just like Chapter 4, let us break the test on purpose to see what failure looks like. Change "Welcome" to "Welcomes":

// index.test.ts
test("home page returns 200 with welcome text", async () => {
  const res = await fetch("http://localhost:3000/");
  expect(res.status).toBe(200);
  const html = await res.text();
  expect(html).toContain("Welcomes");
});

Run it:

$ bun test

You should see the test fail:

index.test.ts:
✗ home page returns 200 with welcome text [3.12ms]
  expect(received).toContain(expected)

  Expected to contain: "Welcomes"
  Received: "<!DOCTYPE html>\n<html>\n<head>..."

The runner shows what it expected, what it actually got (the start of the home page HTML), and stops. Change "Welcomes" back to "Welcome" and run bun test again. Green.

Add the rest of the routes

One test is not enough. We have five routes — let us cover them all:

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

describe("server routes", () => {
  test("home page returns 200 with welcome text", async () => {
    const res = await fetch("http://localhost:3000/");
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("Welcome");
  });

  test("about page returns 200", async () => {
    const res = await fetch("http://localhost:3000/about");
    expect(res.status).toBe(200);
  });

  test("health check returns OK", async () => {
    const res = await fetch("http://localhost:3000/ok");
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("OK");
  });

  test("wildcard route serves the stylesheet", async () => {
    const res = await fetch("http://localhost:3000/style.css");
    expect(res.status).toBe(200);
  });

  test("unknown URL returns 404", async () => {
    const res = await fetch("http://localhost:3000/nope");
    expect(res.status).toBe(404);
    expect(await res.text()).toBe("Page not found");
  });
});

Run the tests:

$ bun test

You should see all five pass:

 5 pass
 0 fail

The shape of every test is the same. Send a real HTTP request with fetch(). Inspect the Response — its status, its body, its headers. The server is already running because we imported ./index.ts at the top of the file. There is nothing else to set up.

Group with describe

Like Chapter 4, let us group the tests under a shared label so the output stays organized as we add more:

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

describe("server routes", () => {
  test("home page returns 200 with welcome text", async () => {
    const res = await fetch("http://localhost:3000/");
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("Welcome");
  });

  test("about page returns 200", async () => {
    const res = await fetch("http://localhost:3000/about");
    expect(res.status).toBe(200);
  });

  test("health check returns OK", async () => {
    const res = await fetch("http://localhost:3000/ok");
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("OK");
  });

  test("wildcard route serves the stylesheet", async () => {
    const res = await fetch("http://localhost:3000/style.css");
    expect(res.status).toBe(200);
  });

  test("unknown URL returns 404", async () => {
    const res = await fetch("http://localhost:3000/nope");
    expect(res.status).toBe(404);
    expect(await res.text()).toBe("Page not found");
  });
});

Run the tests:

$ bun test

The output now nests the tests under server routes:

index.test.ts:
server routes > home page returns 200 with welcome text [2.31ms]
server routes > about page returns 200 [0.84ms]
server routes > health check returns OK [0.62ms]
...

Watch mode

Just like in Chapter 4, you can keep the runner watching for changes:

$ bun test --watch

Edit index.ts or index.test.ts, save, and the suite reruns automatically. This is the rhythm for the rest of the book: leave bun test --watch running in one terminal, edit code in another, get instant feedback. Stop it with Ctrl + C.

Each test pins down one piece of behavior we just built. The home and about pages serve real HTML through the layout. /ok returns the static Response we set as the route value. The wildcard route serves files from public/ when they exist, and returns a 404 when they do not. From this chapter onward, every chapter adds tests for what it builds. The tests are part of the work, not an afterthought.

Save and Commit

Time to save our work. Check the status:

$ git status

You should see new and modified files: index.ts, index.test.ts, layout.html, and the public/ folder.

Add and commit:

$ git add .
$ git commit -m "Add layout, static file serving, health check, and route tests"

Conclusion

You set up the foundation for every page in the app — without adding any dependencies. HTML files served directly with Bun.file, a static health check, a shared layout with placeholders, a single wildcard route that serves any static asset from public/, and a test suite that confirms every route does what it should.

This is the Bun way: reach for the built-in tool first, add a dependency only when the built-in stops being enough.

The key ideas from this chapter:

Bun.file() serves static HTML cleanly. A Response object as a route value gives you a zero-allocation static response — perfect for health checks and fixed content. A /* wildcard route catches anything other routes do not match, which is how one handler can serve every file in public/. A shared layout file with {{placeholder}} substitution gives every page consistent chrome without duplicating boilerplate. Loading the layout once at module load — using Bun's native top-level await — means every request is just a fast string substitution, not a disk read. And a side-effect import of ./index.ts from the test file starts the same server bun run dev would, so tests can hit every route with a real fetch() call — guarded with import.meta.main so the dev-mode log line stays out of test output.

Right now the app shows pages but does not accept input. In the next chapter, we will add a form, handle POST submissions, store notes in memory, and write more tests as we go.

Bun for Beginners
Get the rest of the book.
Part II — eight chapters that turn the routing groundwork from Part I into a real Notes app. Part III ships in a few weeks (production deploy + ops).
·Forms, validation, and application state
·SQLite persistence and user accounts
·Sessions, login, and authorization
·Edit, delete, search, and pagination
Feedback