Skip to content
Dev Tools Intermediate Tutorial

Build a REST API in Go with net/http and the New ServeMux

Ship a dependency-free JSON API with method-aware routing, middleware, and graceful shutdown using only Go's standard library.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Aug 25, 2026 · 8 min read
Build a REST API in Go with net/http and the New ServeMux

What you'll build

A JSON task API with five routes, request logging, panic recovery, and clean shutdown on Ctrl-C — using only the Go standard library. No router package, no framework, no go.sum.

Prerequisites

  • Go 1.22 or newer. The method-and-wildcard routing patterns landed in Go 1.22; this tutorial was verified on Go 1.25.7 and the code uses nothing newer than 1.22 APIs, so it runs unchanged on the current stable release, Go 1.27.0 (released August 19, 2026). Check with go version.
  • curl for testing. macOS and Linux ship it; on Windows use PowerShell's curl.exe (not the curl alias, which is Invoke-WebRequest).
  • Port 8080 free on localhost.

Step 1: Initialize the module

mkdir taskapi && cd taskapi
go mod init example.com/taskapi

Open the generated go.mod and confirm the go directive is 1.22 or higher. This matters more than it looks: the new mux semantics are gated on that directive, and a go 1.21 line silently reverts to the old literal-path matching (see Troubleshooting).

Step 2: Write the store and JSON helpers

An in-memory map behind an RWMutex is enough to exercise the router. The two write* helpers keep every handler's response shape consistent, and setting Content-Type before WriteHeader matters — headers written after the status line are silently dropped.

// store.go
package main

import (
	"encoding/json"
	"net/http"
	"sync"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type store struct {
	mu     sync.RWMutex
	nextID int
	tasks  map[int]Task
}

func newStore() *store {
	return &store{nextID: 1, tasks: make(map[int]Task)}
}

func (s *store) list() []Task {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	return out
}

func (s *store) create(title string) Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	t := Task{ID: s.nextID, Title: title}
	s.tasks[t.ID] = t
	s.nextID++
	return t
}

func (s *store) get(id int) (Task, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	t, ok := s.tasks[id]
	return t, ok
}

func (s *store) delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	_, ok := s.tasks[id]
	delete(s.tasks, id)
	return ok
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

Step 3: Add middleware chaining

Middleware in net/http is just a function from http.Handler to http.Handler. The chain helper applies them in reverse so the first one you list is the outermost — the order you'd read it in. statusRecorder exists because http.ResponseWriter gives you no way to read back the status a handler wrote; wrapping WriteHeader is the standard-library-only answer.

// middleware.go
package main

import (
	"log/slog"
	"net/http"
	"time"
)

type Middleware func(http.Handler) http.Handler

// chain wraps h so that the first middleware listed is the outermost.
func chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

// statusRecorder captures the status code so the logger can report it.
type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", rec.status,
			"duration", time.Since(start),
		)
	})
}

func recoverPanic(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				slog.Error("panic", "err", err, "path", r.URL.Path)
				writeError(w, http.StatusInternalServerError, "internal server error")
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Step 4: Register routes and wire up graceful shutdown

Three things to notice in the ServeMux patterns below. A pattern is [METHOD ][HOST]/[PATH], so "GET /tasks/{id}" matches on method and path together. {id} is a single-segment wildcard you read back with r.PathValue("id"). And a GET pattern also matches HEAD for free. Precedence is "most specific wins": "GET /tasks" and "GET /tasks/{id}" coexist because neither matches a superset of the other's requests. One trap worth knowing before you add a root route: "/" matches every path, so use "/{$}" when you mean exactly /.

Shutdown works by turning SIGINT/SIGTERM into a cancelled context with signal.NotifyContext, then calling srv.Shutdown, which stops accepting connections and waits for in-flight requests to finish (bounded by the 10-second context).

// main.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"
)

func main() {
	s := newStore()
	mux := http.NewServeMux()

	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	})

	mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, s.list())
	})

	mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" {
			writeError(w, http.StatusBadRequest, "body must be JSON with a non-empty title")
			return
		}
		writeJSON(w, http.StatusCreated, s.create(in.Title))
	})

	mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, err := strconv.Atoi(r.PathValue("id"))
		if err != nil {
			writeError(w, http.StatusBadRequest, "id must be an integer")
			return
		}
		t, ok := s.get(id)
		if !ok {
			writeError(w, http.StatusNotFound, "task not found")
			return
		}
		writeJSON(w, http.StatusOK, t)
	})

	mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
		id, err := strconv.Atoi(r.PathValue("id"))
		if err != nil {
			writeError(w, http.StatusBadRequest, "id must be an integer")
			return
		}
		if !s.delete(id) {
			writeError(w, http.StatusNotFound, "task not found")
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           chain(mux, recoverPanic, logRequests),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	go func() {
		slog.Info("listening", "addr", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("server failed", "err", err)
			os.Exit(1)
		}
	}()

	<-ctx.Done()
	slog.Info("shutting down")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		slog.Error("graceful shutdown failed", "err", err)
		os.Exit(1)
	}
	slog.Info("stopped")
}

The explicit timeouts on http.Server aren't optional hygiene — the zero values mean no timeout, so a slow client can hold a connection open indefinitely. ListenAndServe always returns http.ErrServerClosed after a successful Shutdown, which is why that case is filtered out before treating the error as fatal.

Verify it works

Start the server in one terminal:

go run .
2026/08/25 07:36:01 INFO listening addr=:8080

In a second terminal, walk the full lifecycle:

curl -s -X POST localhost:8080/tasks -d '{"title":"write the tutorial"}'
curl -s -X POST localhost:8080/tasks -d '{"title":"ship it"}'
curl -s localhost:8080/tasks/1
curl -si -X DELETE localhost:8080/tasks/1 | head -1
curl -s localhost:8080/tasks

Expected output, line for line:

{"id":1,"title":"write the tutorial","done":false}
{"id":2,"title":"ship it","done":false}
{"id":1,"title":"write the tutorial","done":false}
HTTP/1.1 204 No Content
[{"id":2,"title":"ship it","done":false}]

Now confirm the mux is doing method matching for you. A PUT to a path that only has GET and DELETE handlers gets a 405 with a correct Allow header — no code of yours involved:

curl -si -X PUT localhost:8080/tasks/2 | head -2
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, GET, HEAD

Finally, press Ctrl-C in the server terminal. You should see the shutdown sequence complete and the process exit 0:

2026/08/25 07:36:02 INFO request method=PUT path=/tasks/2 status=405 duration=36.416µs
^C2026/08/25 07:36:02 INFO shutting down
2026/08/25 07:36:02 INFO stopped

Troubleshooting

Every route returns 404 page not found, even GET /tasks. Your go.mod says go 1.21 (or lower). The Go toolchain reads that directive and defaults GODEBUG=httpmuxgo121=1, which makes braces literal and treats "GET /tasks" as a path named GET /tasks. Change the directive to go 1.22 or later and rebuild. To confirm which mode a binary was built with, run go version -m ./taskapi | grep httpmuxgo121 — an affected build lists httpmuxgo121=1 in its DefaultGODEBUG line; a correct one prints nothing.

panic: pattern "GET /tasks/{slug}" (registered at main.go:6) conflicts with pattern "GET /tasks/{id}" (registered at main.go:5). Two patterns match the same set of requests, so neither is more specific and the mux refuses to guess. This fires at registration, not at request time. Rename or remove one; if you want /tasks/latest alongside /tasks/{id}, that's fine — the literal segment is strictly more specific and wins.

ERROR server failed err="listen tcp :8080: bind: address already in use". A previous run is still holding the port — usually one you stopped with Ctrl-Z instead of Ctrl-C, or an IDE debug session. Find it with lsof -i :8080 (macOS/Linux) and kill it, or change Addr to ":8081".

405 Method Not Allowed on a request you expected to work. Check the method casing in your pattern. The mux accepts "get /tasks" without complaint but registers a method literally named get, which never matches an incoming GET. The giveaway is the response header, Allow: get. Methods in patterns must be uppercase.

Next steps

  • Read the Go 1.22 routing enhancements post for the precedence rules and the {path...} and {$} wildcards you'll want for file serving and exact-root matches.
  • Swap the map for a real database and add a PUT /tasks/{id} handler — the pattern will slot in next to DELETE without conflict.
  • Write handler tests with net/http/httptest: httptest.NewRequest plus mux.ServeHTTP exercises routing and PathValue with no network.
  • On Go 1.27 the standard encoding/json is now backed by the new encoding/json/v2 engine; try json.UnmarshalRead(r.Body, &in) from v2 for stricter decoding.

Sources & further reading

  1. net/http ServeMux - pattern syntax, precedence and compatibility — pkg.go.dev
  2. Routing Enhancements for Go 1.22 — go.dev
  3. os/signal NotifyContext — pkg.go.dev
  4. Go 1.27 Release Notes — go.dev
  5. Go Release History — go.dev
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.

Discussion 1

Join the discussion

Sign in or create an account to comment and vote.

Ada Brown @a11y_ada · 25 minutes ago

the no-deps angle is solid—i migrated a cli tool to stdlib routing last year and immediately stopped fielding bug reports about conflicting versions in the ecosystem. my only addition: please test with keyboard nav and screen readers if this gets a web frontend, since i've seen too many go apis ship with admin dashboards that are completely unusable without a mouse.

Related Reading