HowtoGo
Home / Tutorials / Build a JSON Task API
Tutorials

Build a JSON Task API

Build a JSON CRUD API in Go with Gorilla Mux: routing by method, path variables, decoding request bodies, real status codes, and middleware.

This tutorial builds a JSON API for a task list: create, read, update, and delete, each on the method HTTP gives it, each answering with a real status code. You will finish with a service you can curl.

Routing is Gorilla Mux, which is what most Go services use. Type every line yourself. Each step ends with something you can run.

Before you start
  • Go installed. go version should print something.
  • A terminal, and curl for checking each step.
  • The HTTP note if you want the protocol first. You can also pick it up as you go.

1 Set up the project

Make a folder, turn it into a module, and add the router.

Run it
$ mkdir go-task-api
$ cd go-task-api
$ go mod init taskapi
$ go get github.com/gorilla/mux
go: creating new go.mod: module taskapi
go: added github.com/gorilla/mux v1.8.1

Create main.go next to go.mod.

2 Serve one route

A mux router is an http.Handler, so it goes straight into ListenAndServe.

package main

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

    "github.com/gorilla/mux"
)

func main() {
    r := mux.NewRouter()

    r.HandleFunc("/tasks", func(w http.ResponseWriter, req *http.Request) {
        fmt.Fprintln(w, "the task list will live here")
    }).Methods(http.MethodGet)

    log.Println("listening on :8090")
    log.Fatal(http.ListenAndServe(":8090", r))
}
Run it
$ go run .
2026/08/29 14:22:03 listening on :8090

That terminal belongs to the server now. Leave it running and send the requests from a second one.

Run it
$ curl localhost:8090/tasks
$ curl -o /dev/null -w '%{http_code}\n' -X POST localhost:8090/tasks
the task list will live here
405

.Methods is what produced that 405. The path matched and the method did not, so mux refused it without your handler running. Every step after this one changes the code, so stop the server with Ctrl-C and start it again before the next request.

3 Give a task a type and somewhere to live

The struct tags decide the JSON field names. Every request runs in its own goroutine, so the map needs a mutex.

// Task is one item in the list.
type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Done  bool   `json:"done"`
}

// store holds the tasks in memory.
type store struct {
    mu     sync.Mutex
    tasks  map[int]Task
    nextID int
}

func newStore() *store {
    return &store{
        tasks: map[int]Task{
            1: {ID: 1, Title: "Write the handler", Done: true},
            2: {ID: 2, Title: "Add the router", Done: false},
        },
        nextID: 3,
    }
}

Add one helper that every handler will use, and a type to hang the handlers off.

// writeJSON sends v with the right content type and status.
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

type api struct{ store *store }

WriteHeader has to come after the headers and before the body. Once you write a byte, the status is already sent.

4 List every task

Map order is random, so sort by ID before answering. An API that shuffles its own list is hard to trust.

func (s *store) list() []Task {
    s.mu.Lock()
    defer s.mu.Unlock()

    out := make([]Task, 0, len(s.tasks))
    for _, t := range s.tasks {
        out = append(out, t)
    }
    sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
    return out
}

func (a *api) listTasks(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, a.store.list())
}

Wire it up in main, replacing the placeholder handler.

a := &api{store: newStore()}

r := mux.NewRouter()
r.HandleFunc("/tasks", a.listTasks).Methods(http.MethodGet)
Run it
$ curl localhost:8090/tasks
[{"id":1,"title":"Write the handler","done":true},{"id":2,"title":"Add the router","done":false}]

5 Read one task

The {id:[0-9]+} pattern captures a segment and refuses anything that is not digits, so a junk id never reaches the handler.

func (a *api) getTask(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(mux.Vars(r)["id"])
    if err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
        return
    }

    t, ok := a.store.get(id)
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
        return
    }
    writeJSON(w, http.StatusOK, t)
}
r.HandleFunc("/tasks/{id:[0-9]+}", a.getTask).Methods(http.MethodGet)
Run it
$ curl localhost:8090/tasks/1
$ curl -w ' %{http_code}\n' localhost:8090/tasks/99
{"id":1,"title":"Write the handler","done":true}
{"error":"no such task"} 404

mux.Vars hands back map[string]string, so every captured value arrives as text and you convert it yourself.

6 Create a task

Decode the body into a Task, check what matters, then answer 201 with a Location header pointing at the new thing.

func (a *api) createTask(w http.ResponseWriter, r *http.Request) {
    var in Task
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
        return
    }
    if in.Title == "" {
        writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "title is required"})
        return
    }

    t := a.store.add(in.Title)
    w.Header().Set("Location", "/tasks/"+strconv.Itoa(t.ID))
    writeJSON(w, http.StatusCreated, t)
}
r.HandleFunc("/tasks", a.createTask).Methods(http.MethodPost)
Run it
$ curl -w ' %{http_code}\n' -X POST localhost:8090/tasks -d '{"title":"Ship it"}'
$ curl -w ' %{http_code}\n' -X POST localhost:8090/tasks -d '{}'
{"id":3,"title":"Ship it","done":false} 201
{"error":"title is required"} 422

Two routes now share /tasks and split on the method. 400 means the server could not parse it; 422 means it parsed and the content is still wrong.

7 Update a task

PUT replaces the whole task, so the body carries every field. Anything left out is cleared.

func (a *api) updateTask(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(mux.Vars(r)["id"])
    if err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
        return
    }

    var in Task
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
        return
    }

    t, ok := a.store.replace(id, in.Title, in.Done)
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
        return
    }
    writeJSON(w, http.StatusOK, t)
}
r.HandleFunc("/tasks/{id:[0-9]+}", a.updateTask).Methods(http.MethodPut)
Run it
$ curl -X PUT localhost:8090/tasks/2 -d '{"title":"Add the router","done":true}'
{"id":2,"title":"Add the router","done":true}

8 Delete a task

There is nothing to send back, so send 204 and write no body.

func (a *api) deleteTask(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(mux.Vars(r)["id"])
    if err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
        return
    }

    if !a.store.remove(id) {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
        return
    }
    w.WriteHeader(http.StatusNoContent)
}
r.HandleFunc("/tasks/{id:[0-9]+}", a.deleteTask).Methods(http.MethodDelete)
Run it
$ curl -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8090/tasks/1
$ curl localhost:8090/tasks
204
[{"id":2,"title":"Add the router","done":true},{"id":3,"title":"Ship it","done":false}]

All four CRUD operations are live, and the list reflects every one of them.

9 Log every request

Middleware takes a handler and returns one. r.Use wraps every route on the router with it.

// logging wraps every handler, so one line is printed per request.
func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}
r.Use(logging)
Run it
$ curl -s -o /dev/null localhost:8090/tasks
$ curl -s -o /dev/null -X DELETE localhost:8090/tasks/2
2026/08/31 14:02:11 GET /tasks
2026/08/31 14:02:11 DELETE /tasks/2

Those two lines appear in the terminal running the server, with your own timestamps. Auth, request IDs, and panic recovery all go in the same place.

The whole program main.go Show
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "sort"
    "strconv"
    "sync"

    "github.com/gorilla/mux"
)

// Task is one item in the list.
type Task struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
    Done  bool   `json:"done"`
}

// store holds the tasks in memory. Every request runs in its own goroutine,
// so the map is guarded by a mutex.
type store struct {
    mu     sync.Mutex
    tasks  map[int]Task
    nextID int
}

func newStore() *store {
    return &store{
        tasks: map[int]Task{
            1: {ID: 1, Title: "Write the handler", Done: true},
            2: {ID: 2, Title: "Add the router", Done: false},
        },
        nextID: 3,
    }
}

func (s *store) list() []Task {
    s.mu.Lock()
    defer s.mu.Unlock()

    out := make([]Task, 0, len(s.tasks))
    for _, t := range s.tasks {
        out = append(out, t)
    }
    sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
    return out
}

func (s *store) get(id int) (Task, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()

    t, ok := s.tasks[id]
    return t, ok
}

func (s *store) add(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) replace(id int, title string, done bool) (Task, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.tasks[id]; !ok {
        return Task{}, false
    }
    t := Task{ID: id, Title: title, Done: done}
    s.tasks[id] = t
    return t, true
}

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

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

// writeJSON sends v with the right content type and status.
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

// idFrom reads the {id} placeholder mux captured from the path.
func idFrom(r *http.Request) (int, error) {
    return strconv.Atoi(mux.Vars(r)["id"])
}

type api struct{ store *store }

func (a *api) listTasks(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, a.store.list())
}

func (a *api) getTask(w http.ResponseWriter, r *http.Request) {
    id, err := idFrom(r)
    if err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
        return
    }

    t, ok := a.store.get(id)
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
        return
    }
    writeJSON(w, http.StatusOK, t)
}

func (a *api) createTask(w http.ResponseWriter, r *http.Request) {
    var in Task
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
        return
    }
    if in.Title == "" {
        writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "title is required"})
        return
    }

    t := a.store.add(in.Title)
    w.Header().Set("Location", "/tasks/"+strconv.Itoa(t.ID))
    writeJSON(w, http.StatusCreated, t)
}

func (a *api) updateTask(w http.ResponseWriter, r *http.Request) {
    id, err := idFrom(r)
    if err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
        return
    }

    var in Task
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON"})
        return
    }

    t, ok := a.store.replace(id, in.Title, in.Done)
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
        return
    }
    writeJSON(w, http.StatusOK, t)
}

func (a *api) deleteTask(w http.ResponseWriter, r *http.Request) {
    id, err := idFrom(r)
    if err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
        return
    }

    if !a.store.remove(id) {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such task"})
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// logging wraps every handler, so one line is printed per request.
func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

func main() {
    a := &api{store: newStore()}

    r := mux.NewRouter()
    r.Use(logging)

    r.HandleFunc("/tasks", a.listTasks).Methods(http.MethodGet)
    r.HandleFunc("/tasks", a.createTask).Methods(http.MethodPost)
    r.HandleFunc("/tasks/{id:[0-9]+}", a.getTask).Methods(http.MethodGet)
    r.HandleFunc("/tasks/{id:[0-9]+}", a.updateTask).Methods(http.MethodPut)
    r.HandleFunc("/tasks/{id:[0-9]+}", a.deleteTask).Methods(http.MethodDelete)

    log.Println("listening on :8090")
    log.Fatal(http.ListenAndServe(":8090", r))
}

What you built

You have a JSON API with the four CRUD operations, each on its own method, each answering with the status code that fits. A read gets 200, a create gets 201 plus a Location header, a delete gets 204, a caller in the wrong gets 404 or 422, and the router itself sends 405.

Four tools did that work. mux.NewRouter matched paths and methods, mux.Vars read the captured id, encoding/json carried the bodies both ways, and a sync.Mutex kept the map safe across goroutines.

Swapping the map for a database changes only the store methods. The handlers stay as they are.