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:
GETsays that the route answers only this method. It answersHEADtoo, a kind ofGETthat asks for the headers only./aboutis 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.Fprintfcalls 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.StatusForbiddenand its relatives are constants for the numbers. They say what they mean, so use them in place of403.
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 /okreplies with status 200 andAll good.GET /gonereplies withhttp.NotFound.GET /lockedreplies withhttp.Error, the textLocked.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.