HowtoGo
Home / Tutorials / Build a Web Server
Tutorials

Build a Web Server

Build a Go web server from an empty folder: routing with ServeMux, reading request data, rendering HTML safely, and a working QR code generator at the end.

This tutorial takes you from an empty folder to a working Go web server that routes several paths, reads data off a request, and renders HTML without letting a visitor inject their own. Type every line yourself.

By the end you will have a QR code generator running on your machine, and a feel for the handful of net/http pieces that almost every Go web service is built from.

Before you start
  • Go installed. go version should print something.
  • A terminal, and curl for checking each step.

1 Set up the project

Make a folder, turn it into a Go module, and create the one file you will spend the rest of the tutorial in.

mkdir go-web-tutorial
cd go-web-tutorial
go mod init webtutorial

Create a file called main.go next to go.mod.

2 Serve your first response

Open main.go and add this.

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, Gopher!")
    })

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Run it
$ go run main.go
2026/08/29 14:02:11 listening on :8080

That terminal belongs to the server now. Leave it running, open a second one, and send the request from there.

Run it
$ curl localhost:8080
Hello, Gopher!

Every step after this one changes the code, so stop the server with Ctrl-C and start it again before you send the next request.

Two things happened here. http.HandleFunc registered a function to run whenever a request arrives for a path, and http.ListenAndServe opened port 8080 and started handing requests to it. That function is a handler, and writing to its w argument is how a response gets sent.

ListenAndServe only returns when something goes wrong, which is why its result goes straight into log.Fatal.

3 Look at what the request carries

Add a second handler below main.

func echoHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "method: %s\n", r.Method)
    fmt.Fprintf(w, "path:   %s\n", r.URL.Path)
    fmt.Fprintf(w, "host:   %s\n", r.Host)
    fmt.Fprintf(w, "agent:  %s\n", r.UserAgent())
    fmt.Fprintf(w, "from:   %s\n", r.RemoteAddr)
}

Register it in main, under the handler you already have.

    http.HandleFunc("/echo", echoHandler)
Run it
$ curl localhost:8080/echo
method: GET
path:   /echo
host:   localhost:8080
agent:  curl/8.9.1
from:   127.0.0.1:51420

A handler's second argument holds everything the client sent. r.Method, r.URL, and r.Header are the three you reach for constantly, and UserAgent is a shortcut for one particular header.

4 Control the status code and headers

So far every response has been a 200. Add a handler that says otherwise.

func createdHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    w.Header().Set("X-Powered-By", "net/http")
    w.WriteHeader(http.StatusCreated)
    fmt.Fprintln(w, "created")
}
    http.HandleFunc("/created", createdHandler)
Run it
$ curl -i localhost:8080/created
HTTP/1.1 201 Created
X-Powered-By: net/http
Content-Type: text/plain; charset=utf-8

created

The order matters. Headers have to be set before WriteHeader, and WriteHeader before anything is written to the body. Once bytes start flowing, the status line has already gone out and a later change is ignored.

Skipping WriteHeader entirely sends a 200 on the first write, which is why the earlier handlers worked without it.

5 Route with your own ServeMux

http.HandleFunc registers on a package-level router shared by everything in the process. Build your own instead, and say which HTTP methods each route accepts. Replace main with this.

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, Gopher!")
    })
    mux.HandleFunc("GET /echo", echoHandler)
    mux.HandleFunc("POST /created", createdHandler)

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
Run it
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/created
$ curl -s -X POST localhost:8080/created
405
created

A GET to /created now gets a 405 without the handler running at all. The method prefix is part of the pattern, so the router rejects the wrong verb for you.

Passing mux to ListenAndServe in place of nil is what puts your router in charge. nil meant "use the package-level one".

6 Read data off the request

Add a handler that reads a query parameter.

func greetHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        name = "stranger"
    }
    fmt.Fprintf(w, "Hello, %s!\n", name)
}
    mux.HandleFunc("GET /greet", greetHandler)
Run it
$ curl "localhost:8080/greet?name=Keith"
$ curl localhost:8080/greet
Hello, Keith!
Hello, stranger!

Query().Get returns an empty string for a parameter that was not sent, so a missing value and an empty one look the same. Any default belongs in the handler, as the if above does it.

7 Render HTML without opening a hole

Printing user input straight into a page lets a visitor put their own markup on it. html/template escapes values on the way in, based on where in the document they land.

import "html/template"

var greetPage = template.Must(template.New("greet").Parse(
    `<h1>Hello, {{.}}</h1>`))

func pageHandler(w http.ResponseWriter, r *http.Request) {
    greetPage.Execute(w, r.URL.Query().Get("name"))
}
    mux.HandleFunc("GET /page", pageHandler)
Run it
$ curl "localhost:8080/page?name=<script>alert(1)</script>"
<h1>Hello, &lt;script&gt;alert(1)&lt;/script&gt;</h1>

The script tag came back as text rather than running. template.Must wraps the parse so a broken template panics at startup instead of on the first request that hits it.

Importing text/template by mistake gives the same API with none of the escaping. For anything a browser will render, it has to be html/template.

8 Make the address configurable

A hard-coded port is fine until two of these run at once. Add a flag.

import "flag"

var addr = flag.String("addr", ":8080", "HTTP service address")

func main() {
    flag.Parse()

    // ... mux and handlers as before ...

    log.Printf("listening on %s", *addr)
    log.Fatal(http.ListenAndServe(*addr, mux))
}
Run it
$ curl localhost:9090
Hello, Gopher!

flag.String hands back a *string, and the value only exists after flag.Parse has run. That is why the flag is a package-level variable and Parse is the first line of main.

9 Put it together as a QR generator

Every piece is now in place: a configurable address, your own router, a handler reading request data, and a template rendering it safely. Here is the whole thing as one small program that turns whatever text you give it into a QR code.

main.go 44 lines Show
package main

import (
    "flag"
    "html/template"
    "log"
    "net/http"
)

var addr = flag.String("addr", ":8080", "HTTP service address")

var qrTemplate = template.Must(template.New("qr").Parse(templateStr))

func main() {
    flag.Parse()

    mux := http.NewServeMux()
    mux.HandleFunc("GET /", qrHandler)

    log.Printf("listening on %s", *addr)
    log.Fatal(http.ListenAndServe(*addr, mux))
}

func qrHandler(w http.ResponseWriter, r *http.Request) {
    if err := qrTemplate.Execute(w, r.FormValue("s")); err != nil {
        log.Println("render failed:", err)
    }
}

const templateStr = `<!DOCTYPE html>
<html>
<head><title>QR Link Generator</title></head>
<body>
{{if .}}
<img src="https://api.qrserver.com/v1/create-qr-code/?size=300x300&data={{.}}">
<p>{{.}}</p>
{{end}}
<form action="/" method="GET">
    <input maxlength="1024" size="70" name="s" value="" title="Text to QR Encode">
    <input type="submit" value="Generate QR">
</form>
</body>
</html>`
Run it
$ open "http://localhost:8080/?s=https://howtogo.dev"
listening on :8080

FormValue reads a query parameter on a GET and a parsed form field on a POST, so the same handler serves the empty form and the submitted one.

What you built

You have a web server that routes by path and method, reports what a request carried, sets its own status codes and headers, reads user input, renders it into HTML without letting a visitor inject markup, and takes its listen address from the command line.

Five pieces did that work. http.ServeMux routed, the handler signature gave you the request and the response writer, r.URL.Query and FormValue read input, html/template escaped output, and flag handled configuration. Almost every Go web service is these five, with more handlers.