Updated Jun 23, 2026

Building a REST API

This is the payoff phase. Everything so far has been a separate piece on the workbench — the router from Phase 2, the middleware stack from Phase 3, the JSON read/write helpers from Phase 4. Now we bolt them together into a real, working REST API for the articles resource. By the end you'll have full CRUD (create, read, update, delete) that you can hit with curl.

Here's the thing to hold in your head before any code:

📝 A REST resource is just five plain http.HandlerFuncs over one collection, mounted on a sub-router. That's the whole mental model. List, get-one, create, update, delete — five functions with the identical signature func(w http.ResponseWriter, r *http.Request). There's no framework "context" object, no special base class, no magic. It's the same conceptual shape you'd draw for Gin or Echo, but here every handler is pure standard library plus chi's router doing the method-and-path matching. If you understand these five, you understand REST in Go.

Let's build it from the inside out: first the place the data lives, then the five handlers, then the routing that wires them up.

The store: where the articles live

Before we can serve articles, we need somewhere to keep them. In a real app this is a database. To keep this phase about the API and not about SQL, we'll use an in-memory store: a map from ID to Article, plus a counter for the next ID.

But there's a trap here that catches people, so let's name it loudly.

⚠️ net/http serves every request on its own goroutine. That means two requests can hit your store at the same time — one creating an article while another lists them. A plain Go map is not safe for concurrent read/write; do it unguarded and you'll get a runtime panic ("concurrent map writes") under load, the kind of bug that never shows up in local testing and then takes your server down in production. The fix is a sync.RWMutex guarding every access.

type Article struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Body  string `json:"body"`
}

type store struct {
    mu       sync.RWMutex
    articles map[int]Article
    nextID   int
}

func newStore() *store {
    return &store{
        articles: map[int]Article{},
        nextID:   1,
    }
}

What just happened: Article is the same struct from Phase 4 — plain fields with JSON tags. store wraps the map together with the mutex that protects it and the nextID counter. Keeping the mutex next to the data it guards (rather than as a loose global) is the idiomatic Go move: it's obvious what the lock protects. newStore hands back a ready-to-use store with an empty map and IDs starting at 1.

Now the store's methods. The rule of thumb: take a read lock (RLock) when you're only looking, take a write lock (Lock) when you're changing anything. Read locks can be held by many goroutines at once; a write lock is exclusive.

func (s *store) list() []Article {
    s.mu.RLock()
    defer s.mu.RUnlock()

    out := make([]Article, 0, len(s.articles))
    for _, a := range s.articles {
        out = append(out, a)
    }
    return out
}

func (s *store) get(id int) (Article, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    a, ok := s.articles[id]
    return a, ok
}

func (s *store) create(title, body string) Article {
    s.mu.Lock()
    defer s.mu.Unlock()

    a := Article{ID: s.nextID, Title: title, Body: body}
    s.articles[a.ID] = a
    s.nextID++
    return a
}

func (s *store) update(id int, title, body string) (Article, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.articles[id]; !ok {
        return Article{}, false
    }
    a := Article{ID: id, Title: title, Body: body}
    s.articles[id] = a
    return a, true
}

func (s *store) delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.articles[id]; !ok {
        return false
    }
    delete(s.articles, id)
    return true
}

What just happened: five small methods, each one locking before it touches the map and defer-ing the unlock so it always releases even if something returns early. list and get use RLock (read-only). create, update, and delete use Lock (they mutate). Notice the (value, bool) pattern on get, update, and delete: the bool says "did it exist?" — that's how the handlers will know whether to return a 404. create builds the Article with the server-assigned ID, never trusting the client to pick one.

💡 The methods returning bool instead of an error is deliberate: "not found" isn't really an error here, it's a normal outcome the handler maps to a 404. We'll keep error-shaped failures (like a bad ID in the URL) at the handler layer.

The five handlers

Now the heart of it. Each handler is a closure over the store so it can reach the data, and each one is a plain func(w http.ResponseWriter, r *http.Request). We reuse the writeJSON helper from Phase 4 verbatim — here it is again so this phase stands alone:

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

What just happened: exactly the Phase 4 helper — set the header, write the status, encode the body, in that locked order. We won't re-explain it; the point is that all five handlers respond through it and never repeat that dance.

We'll also need one tiny shared step: pulling the id out of the URL and turning it into an int. Three of the five handlers do this, so look at it once here and recognize it when it reappears:

idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
    http.Error(w, "id must be a number", http.StatusBadRequest)
    return
}

What just happened: chi.URLParam(r, "id") reads the {id} segment from the path (chi's one I/O helper). It always hands back a string, so strconv.Atoi converts it to an int. If the URL had /articles/abc, Atoi fails and we return 400 — a non-numeric id is the client's mistake, not the server's.

Now the handlers themselves.

list — GET the whole collection (200)

func listArticles(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, s.list())
    }
}

What just happened: the simplest one. listArticles is a function that returns a handler (a closure capturing s). The handler asks the store for every article and writes them as a JSON array with 200 OK. Even when the list is empty, list() returns a non-nil empty slice, so the client gets [], not null — a small kindness that keeps clients from special-casing the empty case.

get — GET one by id (200 or 404)

func getArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            http.Error(w, "id must be a number", http.StatusBadRequest)
            return
        }

        a, ok := s.get(id)
        if !ok {
            http.Error(w, "article not found", http.StatusNotFound)
            return
        }

        writeJSON(w, http.StatusOK, a)
    }
}

What just happened: parse the id (400 if it's not a number), then ask the store. The store's bool does the work: if ok is false, the article doesn't exist and we return 404 and stop. Otherwise we write the single article with 200. This is the canonical get-one shape — two ways to fail, one way to succeed.

create — POST a new one (201, with decode + manual validation)

func createArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var in struct {
            Title string `json:"title"`
            Body  string `json:"body"`
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
            http.Error(w, "invalid JSON body", http.StatusBadRequest)
            return
        }

        if in.Title == "" {
            http.Error(w, "title is required", http.StatusBadRequest)
            return
        }

        a := s.create(in.Title, in.Body)
        writeJSON(w, http.StatusCreated, a)
    }
}

What just happened: the busiest handler, and it earns it. First, decode the request body into an input struct (in) — note it has no ID field, because the client doesn't get to choose the id. A decode failure is malformed JSON, so 400. Next, the part the stdlib won't do for you: validation by hand. We check in.Title == "" and reject empty titles with a 400. (Add more checks here as your rules grow — there's no validator unless you bring one.) Finally, s.create stores it with a fresh server-assigned ID and we reply 201 Created with the full new article, so the client learns the id it was given.

update — PUT to replace one (200 or 404)

func updateArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            http.Error(w, "id must be a number", http.StatusBadRequest)
            return
        }

        var in struct {
            Title string `json:"title"`
            Body  string `json:"body"`
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
            http.Error(w, "invalid JSON body", http.StatusBadRequest)
            return
        }
        if in.Title == "" {
            http.Error(w, "title is required", http.StatusBadRequest)
            return
        }

        a, ok := s.update(id, in.Title, in.Body)
        if !ok {
            http.Error(w, "article not found", http.StatusNotFound)
            return
        }

        writeJSON(w, http.StatusOK, a)
    }
}

What just happened: update is "get-one and create had a baby" — it parses the id and decodes a body, validating both. The store's update returns the same (value, bool): if the id doesn't exist, 404; otherwise the article is replaced and we return the updated version with 200. We use PUT here, meaning "replace the whole article with this." (A partial update — change only the title, leave the body — would be PATCH, which is fiddlier because you have to distinguish "field omitted" from "field set to empty"; PUT sidesteps that.)

delete — DELETE one (204 or 404)

func deleteArticle(s *store) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
            http.Error(w, "id must be a number", http.StatusBadRequest)
            return
        }

        if !s.delete(id) {
            http.Error(w, "article not found", http.StatusNotFound)
            return
        }

        w.WriteHeader(http.StatusNoContent)
    }
}

What just happened: parse the id, ask the store to delete. If it wasn't there, 404. If it was, we set 204 No Content and write nothing — no writeJSON, no body at all, because 204 means "success, and there's nothing to send back." (This is the empty-body rule from Phase 4; deletes are its textbook use.)

Wiring it up with a sub-router

Five handlers, and now the routing that connects HTTP methods and paths to them. This is where Phase 2's r.Route shines: we mount the whole resource under one path prefix and nest the per-id routes inside it.

func main() {
    s := newStore()

    r := chi.NewRouter()
    r.Use(middleware.Logger)

    r.Route("/api/v1/articles", func(r chi.Router) {
        r.Get("/", listArticles(s))
        r.Post("/", createArticle(s))

        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getArticle(s))
            r.Put("/", updateArticle(s))
            r.Delete("/", deleteArticle(s))
        })
    })

    http.ListenAndServe(":3000", r)
}

What just happened: one store, one router, and the resource laid out as a tree. The outer r.Route("/api/v1/articles", ...) groups everything under that prefix. Inside it, Get("/") and Post("/") handle the collection (/api/v1/articles itself) — list and create. The nested r.Route("/{id}", ...) handles a single item (/api/v1/articles/42), with Get/Put/Delete mapping to get/update/ delete. Read the registration top to bottom and it is the REST table — methods on the left, handlers on the right, paths from the nesting. middleware.Logger (from Phase 3) wraps the whole thing so every request gets logged. Each handler is called with s to produce the actual http.HandlerFunc, threading the shared store into all five.

💡 The version prefix /api/v1/ is a cheap insurance policy. When you eventually ship a breaking change, you add /api/v2/ alongside it and old clients keep working. Costs you nothing today; saves you a migration headache later.

Driving it with curl

Start the server and exercise the whole lifecycle. Here's the tour, request and response side by side:

# Create an article -> 201
$ curl -s -X POST localhost:3000/api/v1/articles \
    -H 'Content-Type: application/json' \
    -d '{"title":"Hello chi","body":"My first article."}'
{"id":1,"title":"Hello chi","body":"My first article."}

# List them all -> 200
$ curl -s localhost:3000/api/v1/articles
[{"id":1,"title":"Hello chi","body":"My first article."}]

# Get one by id -> 200
$ curl -s localhost:3000/api/v1/articles/1
{"id":1,"title":"Hello chi","body":"My first article."}

# Update it -> 200
$ curl -s -X PUT localhost:3000/api/v1/articles/1 \
    -H 'Content-Type: application/json' \
    -d '{"title":"Hello chi (edited)","body":"Now with edits."}'
{"id":1,"title":"Hello chi (edited)","body":"Now with edits."}

# Delete it -> 204 (no body). Show the status code to prove it:
$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/api/v1/articles/1
204

# Ask for it again -> 404
$ curl -s localhost:3000/api/v1/articles/1
article not found

What just happened: the full CRUD cycle, every status code from our handlers showing up exactly where designed. Create gave a 201 and echoed back the id the server assigned. List returned a JSON array. The DELETE returns no body, so we used -w '%{http_code}' to print the bare status (204) and confirm it. The final GET after the delete returns the 404 plain-text message from http.Error — proof the article is really gone. If you POST a body with no title, you'll get a 400 ("title is required"); if you GET /api/v1/articles/abc, you'll get a 400 ("id must be a number").

The store is a stand-in

One last point, and it's the important one for where you're headed.

💡 That in-memory store is a database stand-in. We used a map + mutex so this phase could be about the API shape without dragging in SQL. But look at the five handlers: not one of them knows or cares that the data lives in a map. They call s.list(), s.get(id), s.create(...), s.update(...), s.delete(id) — five methods. Swap the store's insides for real persistence with GORM and those five methods become database queries, while the handlers, the routing, and the validation barely change. The mutex disappears (the database handles concurrency), but the seams you've drawn here are exactly the seams a real app uses. That's not an accident — it's why we separated the store from the handlers in the first place.

The next phase makes that separation official: how to lay out handlers and services in real files, how to pass dependencies cleanly with context, and how to test all of this with httptest so you never have to curl by hand again.

Recap

  • A REST resource is five plain http.HandlerFuncs over one collection, mounted on a sub-router — same shape as Gin/Echo, but pure stdlib + chi routing, no framework context.
  • The in-memory store is a map[int]Article guarded by a sync.RWMutex. ⚠️ net/http serves requests concurrently, so an unguarded map will panic — RLock to read, Lock to write.
  • Read the id with chi.URLParam(r, "id") then strconv.Atoi (400 if it's not a number); decode bodies with json.NewDecoder(r.Body).Decode; reply through the Phase-4 writeJSON helper.
  • Status codes map cleanly: list/get/update 200, create 201, delete 204 (no body), missing item 404, bad input 400. Validation is by hand — the stdlib won't do it for you.
  • The (value, bool) pattern from the store methods is what drives the 404 decision in the handlers.
  • The store is a database stand-in — swap in GORM later and the handlers barely change, because the store/handler seam is the real one.

Quick check

[
  {
    "q": "Why must the in-memory map be guarded by a sync.RWMutex?",
    "choices": ["Maps are slow without a lock", "net/http serves each request on its own goroutine, so concurrent map writes would panic", "chi requires a mutex on every handler", "It makes JSON encoding thread-safe"],
    "answer": 1,
    "explain": "net/http handles requests concurrently on separate goroutines. A plain Go map is not safe for concurrent read/write and will panic, so every access is guarded — RLock to read, Lock to write."
  },
  {
    "q": "A successful DELETE handler should return which status, and with what body?",
    "choices": ["200 OK with the deleted article as JSON", "404 Not Found with no body", "204 No Content with no body at all", "201 Created with an empty object"],
    "answer": 2,
    "explain": "A successful delete returns 204 No Content and writes nothing — 204 means success with no body, so you set the status and stop (no writeJSON)."
  },
  {
    "q": "In createArticle, why decode into a small input struct with only Title and Body instead of straight into Article?",
    "choices": ["Article has too many fields to decode", "encoding/json can't decode into a struct with an int field", "The client doesn't get to pick the id — the server assigns it, so ID isn't accepted from the body", "It makes the response faster"],
    "answer": 2,
    "explain": "The input struct is 'what the client may send.' Leaving ID out means the client can't set it; the server assigns the id in s.create, keeping it authoritative."
  }
]

← Phase 4: Requests & Responses with the Standard Library · Guide overview · Phase 6: Structuring & Testing →

Check your understanding

1. Why must the in-memory map be guarded by a sync.RWMutex?

2. A successful DELETE handler should return which status, and with what body?

3. In createArticle, why decode into a small input struct with only Title and Body instead of straight into Article?

Was this page helpful?