32. Your first web server

The server in Chapter 31 gave the same answer to every path. A real one has a different answer for each path, and refuses what it cannot do. This chapter adds routes, HTML pages, headers and status codes.

Routes

A route connects a pattern to a handler. Here is exercises/32/01-routes.go:

// Chapter 32 example: several routes with Go 1.22 patterns.
// Run with: go run exercises/32/01-routes.go

package main

import (
    "fmt"
    "net/http"
)

func handleHome(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "This is the home page.")
}

func handleAbout(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "This server was written in Go.")
}

func main() {
    http.HandleFunc("GET /{$}", handleHome)
    http.HandleFunc("GET /about", handleAbout)

    fmt.Println("Server running at http://localhost:8080")
    fmt.Println("Press Ctrl+C to stop it.")

    err := http.ListenAndServe("localhost:8080", nil)
    if err != nil {
        fmt.Println("Server stopped:", err)
    }
}

Run it from go-work. With curl.exe -i, you can check each route. This book shows only the first line and the body from here on, and leaves out headers that do not matter to the example.

curl.exe -i http://localhost:8080/
HTTP/1.1 200 OK
This is the home page.

curl.exe -i http://localhost:8080/about
HTTP/1.1 200 OK
This server was written in Go.

curl.exe -i http://localhost:8080/nothing
HTTP/1.1 404 Not Found
404 page not found

You wrote no code for that 404. The router sends it when no pattern matches.

Reading a pattern

Go 1.22 added methods and wildcards to patterns. "GET /about" has two parts, with one space between them:

  • GET says that the route answers only this method. It answers HEAD too, a kind of GET that asks for the headers only.
  • /about is the path.

Send another method to that path, and Go gives the status 405 by itself. -X POST asks curl.exe to use POST:

curl.exe -i -X POST http://localhost:8080/about
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD

Method Not Allowed

Allow is a header that lists what would have worked.

"GET /{$}" looks strange, and it is there for a reason. A pattern that ends in / matches everything below it. So "GET /" alone would catch /nothing too, and reply with the home page and status 200. The {$} means "and stop here", so the pattern matches / exactly.

When more than one pattern matches, the most specific one wins, so the order of the http.HandleFunc lines does not matter.

These two mistakes do not stop the program, so they are hard to spot:

You wrote What happens
"GET/about" With no space, Go reads GET as a computer name. /about gives 404.
"get /about" Methods are case sensitive. A browser sends GET, and gets 405.

Registering the same pattern twice crashes the program as soon as it starts:

panic: pattern "GET /about" (registered at C:\go-work\exercises\32\01-routes.go:22) conflicts with pattern "GET /about" (registered at C:\go-work\exercises\32\01-routes.go:21):
GET /about matches the same requests as GET /about

Open exercises/32/01-routes.go. Add a route GET /hours that replies with your opening hours. Then try /hours/, with a slash at the end, and see the status.

The two parameters

A handler always has the same two parameters.

r *http.Request is what the visitor sent. So far you have used r.Method and r.URL.Path. Chapter 33 reads the rest.

w http.ResponseWriter is your side of the conversation. It is an interface with three methods:

  • w.Write, which sends part of the body. fmt.Fprintf calls it for you.
  • w.Header(), which gives you the headers, so that you can set them.
  • w.WriteHeader(code), which sets the status. If you do not call it, the status is 200.

The order matters, because a response goes out from the top down: status, headers, then body. So first set the headers, then the status, then write the body. Once the body has started, it is too late. Calling WriteHeader after writing does not change the status, and Go complains in the terminal:

2026/11/05 01:33:24 http: superfluous response.WriteHeader call from main.handleCreated (03-status-codes.go:29)

Setting a header after writing is simply ignored, with no message at all.

Sending HTML

A browser shows plain text as it is. To get a heading or a link, send HTML, which is text with tags in angle brackets. This book assumes only a little of it: <h1> is a big heading, <p> is a paragraph, <a href="..."> is a link, and <ul> and <li> make a list.

The browser has to be told that it is getting HTML. That is what the Content-Type header does. Here is the important part of exercises/32/02-html-page.go:

const homePage = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Home</title>
</head>
<body>
<h1>Welcome</h1>
<p>This page came from a Go string.</p>
<p><a href="/about">About this server</a></p>
</body>
</html>
`

func handleHome(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprint(w, homePage)
}

The page is a raw string in backticks, from Chapter 12, so that it can span lines and contain ". fmt.Fprint writes it with no format and no extra line ending.

charset=utf-8 says how the characters are stored, so that text such as é shows correctly. Always include it.

If you leave out the header, Go looks at the start of the body and guesses. Here it would guess right. But a guess is not something to rely on, so set it yourself.

Open exercises/32/02-html-page.go. Run it, and click the link. Change text/html to text/plain in one handler, and restart. What does the browser show now?

Status codes

exercises/32/03-status-codes.go has one handler for each way of setting a status:

func handleOld(w http.ResponseWriter, r *http.Request) {
    http.NotFound(w, r)
}

func handlePrivate(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "You may not see this page.", http.StatusForbidden)
}

func handleBroken(w http.ResponseWriter, r *http.Request) {
    http.Error(w, "Something went wrong on the server.", http.StatusInternalServerError)
}

func handleCreated(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusCreated)
    fmt.Fprintln(w, "This reply has the status 201 Created.")
}

The results:

curl.exe -i http://localhost:8080/old
HTTP/1.1 404 Not Found
404 page not found

curl.exe -i http://localhost:8080/private
HTTP/1.1 403 Forbidden
You may not see this page.

curl.exe -i http://localhost:8080/broken
HTTP/1.1 500 Internal Server Error
Something went wrong on the server.

curl.exe -i http://localhost:8080/created
HTTP/1.1 201 Created
This reply has the status 201 Created.
  • http.NotFound(w, r) sends the same 404 as the router.
  • http.Error(w, text, code) sends the status and the text as plain text. Use it for most errors.
  • http.StatusForbidden and its relatives are constants for the numbers. They say what they mean, so use them in place of 403.

Neither function ends your handler. They write a response, and then your code carries on. So write return after them, when anything follows. The next chapter shows what happens if you forget.

Serving files from a folder

Sometimes a page needs a file exactly as it is, such as a stylesheet. Go can serve a whole folder with two lines. This example needs its folder, so it is a module of its own, in exercises/32/04-static-files/:

04-static-files/
    go.mod
    main.go
    static/
        notes.txt
        style.css

The two lines, inside main:

files := http.FileServer(http.Dir("static"))
http.Handle("GET /static/", http.StripPrefix("/static/", files))

http.FileServer is a ready-made handler that answers with files from a folder. http.StripPrefix takes /static/ off the path first, so /static/notes.txt looks for notes.txt inside the folder. Because this handler is a value with a method and not a function of yours, it goes in with http.Handle and not http.HandleFunc.

The folder name is relative to where the program runs, so run it from its own folder:

cd exercises/32/04-static-files
go run .

To go back up to go-work afterwards:

cd ../../..

Open http://localhost:8080, and the heading is blue because of the file. The page links to its stylesheet by a local path. Everything the page needs comes from your own server. Requests for files outside the folder do not work:

curl.exe -i http://localhost:8080/static/notes.txt
HTTP/1.1 200 OK
These notes are a file in the static folder.

curl.exe -i http://localhost:8080/main.go
HTTP/1.1 404 Not Found
404 page not found

A file server serves everything in its folder, and a request for /static/ lists the file names. Put only public files in it. The rest of this book does not need a file server.

Homework

Homework starters are in exercises/32/homework/. Each one runs as it is and replies with a placeholder line.

Problem 1 — Three pages

Open exercises/32/homework/01-three-pages.go. Make three routes that answer only GET: the home page at /, a page at /about, and a page at /contact. Each one replies with one line of plain text. Any other path must give 404, including /contact/extra.

Problem 2 — An HTML page

Open exercises/32/homework/02-html-page.go. Reply at / with an HTML page that has a heading, a paragraph, and a list of three things you like. Keep the HTML in a constant, and set the Content-Type header.

Problem 3 — Status codes

Open exercises/32/homework/03-status-codes.go. Add three routes:

  • GET /ok replies with status 200 and All good.
  • GET /gone replies with http.NotFound.
  • GET /locked replies with http.Error, the text Locked. and status 403.

Check the status of each one with curl.exe -i.

Challenge — A small site

Open exercises/32/homework/04-small-site.go. Write a helper function:

func writePage(w http.ResponseWriter, status int, title string, body string)

It sets the Content-Type, sets the status, and writes a whole HTML page with that title and body. Use it from three handlers: the home page, /about, and a page of your own. Every page has links to the other two. Then make your own 404 page: the pattern "GET /" catches any GET path that no other route matches, so a handler there can call writePage with http.StatusNotFound.

Stuck or finished? Open the homework solutions page.