Build a Go REST API

A complete JSON REST API in pure Go standard library — net/http routing, an in-memory store guarded by a mutex, persisted to a JSON file so data survives restarts. No dependencies, verified building and serving.

Go's standard library is enough to build a real REST API — no framework, no dependencies. This build is a complete CRUD service for "items": net/http handles the routing, an in-memory map guarded by a mutex holds the data, and every change is written to a JSON file so the store survives restarts. Because it's pure standard library, the whole thing compiles to a single binary with nothing to install.

At a glance
You need A server running the Go application, and Go on your own computer to build
Plan Any plan — free runs while your session timer has time left, premium runs 24/7
Time About twenty-five minutes
No server yet? Create your first app server

🎯 The one Go rule on Falix: the Go application runs a prebuilt binary named app — nothing compiles on the server. So the real task is getting a Linux binary onto the server. Go on Falix covers the two ways (build locally and upload, or build on Git deploy) in full — this guide gives you the code and points there for the build-and-upload flow.

Step 1 — The files

go.mod — this uses Go 1.22's routing, so declare that version:

module falix-items

go 1.22

main.go — the whole API:

package main

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

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

// Store is an in-memory map guarded by a mutex, persisted to a JSON file.
type Store struct {
    mu     sync.Mutex
    items  map[int]Item
    nextID int
    file   string
}

func NewStore(file string) *Store {
    s := &Store{items: map[int]Item{}, nextID: 1, file: file}
    s.load()
    return s
}

func (s *Store) load() {
    data, err := os.ReadFile(s.file)
    if err != nil {
        return // no file yet — start empty
    }
    var list []Item
    if json.Unmarshal(data, &list) == nil {
        for _, it := range list {
            s.items[it.ID] = it
            if it.ID >= s.nextID {
                s.nextID = it.ID + 1
            }
        }
    }
}

func (s *Store) save() {
    list := make([]Item, 0, len(s.items))
    for _, it := range s.items {
        list = append(list, it)
    }
    if data, err := json.MarshalIndent(list, "", "  "); err == nil {
        os.WriteFile(s.file, data, 0644)
    }
}

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

func (s *Store) list(w http.ResponseWriter, r *http.Request) {
    s.mu.Lock()
    defer s.mu.Unlock()
    list := make([]Item, 0, len(s.items))
    for _, it := range s.items {
        list = append(list, it)
    }
    writeJSON(w, http.StatusOK, list)
}

func (s *Store) create(w http.ResponseWriter, r *http.Request) {
    var in Item
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
        return
    }
    s.mu.Lock()
    defer s.mu.Unlock()
    in.ID = s.nextID
    s.nextID++
    s.items[in.ID] = in
    s.save()
    writeJSON(w, http.StatusCreated, in)
}

func (s *Store) get(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))
    s.mu.Lock()
    defer s.mu.Unlock()
    it, ok := s.items[id]
    if !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
        return
    }
    writeJSON(w, http.StatusOK, it)
}

func (s *Store) update(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))
    var in Item
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
        return
    }
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, ok := s.items[id]; !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
        return
    }
    in.ID = id
    s.items[id] = in
    s.save()
    writeJSON(w, http.StatusOK, in)
}

func (s *Store) remove(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, ok := s.items[id]; !ok {
        writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
        return
    }
    delete(s.items, id)
    s.save()
    writeJSON(w, http.StatusOK, map[string]int{"deleted": id})
}

func main() {
    store := NewStore("data.json")

    mux := http.NewServeMux()
    mux.HandleFunc("GET /items", store.list)
    mux.HandleFunc("POST /items", store.create)
    mux.HandleFunc("GET /items/{id}", store.get)
    mux.HandleFunc("PUT /items/{id}", store.update)
    mux.HandleFunc("DELETE /items/{id}", store.remove)

    addr := ":" + os.Getenv("SERVER_PORT")
    println("Listening on", addr)
    http.ListenAndServe(addr, mux)
}

Step 2 — How it works

  • Routing (Go 1.22). The standard http.ServeMux now understands method + path patterns like "GET /items/{id}", and r.PathValue("id") reads the placeholder. That's why go.mod says go 1.22 — this routing needs it. No third-party router required.
  • The store is a map + a mutex. items map[int]Item holds the data; sync.Mutex guards it so two simultaneous requests can't corrupt the map. A web server handles requests concurrently, so this lock is not optional.
  • Persistence. load() reads data.json at startup (and rebuilds nextID); save() writes the whole map back after every change. So the store survives a restart — stop and start the server and your items are still there.
  • The port rule. addr := ":" + os.Getenv("SERVER_PORT") binds every interface on the public port — the empty host means "listen on all", which is what makes the API reachable. See Your first web app.
Endpoint Method Does
/items GET List all items
/items POST Create one (assigns id, returns 201)
/items/{id} GET Fetch one, or 404
/items/{id} PUT Replace one, or 404
/items/{id} DELETE Remove one, or 404

Step 3 — Build, upload, and run

Nothing compiles on the server, so build a Linux binary named app on your computer and put it on the server. From the project folder:

GOOS=linux GOARCH=amd64 go build -o app

That produces one self-contained app file (Go links everything in — no runtime to ship). Upload it into your server's root folder with the File Manager or over SFTP, then press Start. Prefer pushing source over uploading binaries? Connect the Git page and add a go build -o app post-deploy command so each deploy compiles a fresh binary. Both paths — and the exact steps — are in Go on Falix.

⚠️ Heads up: Build for linux/amd64. A Windows or Mac build won't run on the server (exec format error), and there's no compiler on the server to fix it.

Step 4 — Verify it works

Start the server; the console prints Listening on :NNNNN and stays running. Exercise the API from any client:

# Create, then list
curl -X POST -d '{"title":"Write guide"}' http://YOUR_ADDRESS:PORT/items
curl http://YOUR_ADDRESS:PORT/items

# Update and delete
curl -X PUT -d '{"title":"Write guide","done":true}' http://YOUR_ADDRESS:PORT/items/1
curl -X DELETE http://YOUR_ADDRESS:PORT/items/2

To confirm persistence, add an item, then restart the server — list /items again and it's still there, reloaded from data.json.

Where the data lives

data.json sits in the server's file system next to app.

⚠️ Heads up: A reinstall — or switching applications — wipes the server's files, both data.json and the app binary. The binary you rebuild and re-upload (or a Git deploy rebuilds); the data you don't get back, so back it up if it matters. A single JSON file is ideal for a small service; for real scale or durability, move to a managed database — Go's database/sql connects with the string the panel gives you.

Troubleshooting

  • ./app: not found or a permission error on start — there's no app binary in the root, or it's the wrong OS. Rebuild with GOOS=linux GOARCH=amd64 go build -o app.
  • exec format error — the binary is for the wrong architecture; build for linux/amd64.
  • Build fails with an unknown routing pattern — your local Go is older than 1.22. Update Go, or the "GET /items" patterns won't compile.
  • Starts then stops with no error — a program whose main returns exits cleanly; a web server stays up because it blocks on ListenAndServe. See My app won't start.
  • Runs but the API is unreachable — a port or bind issue: I can't reach my app.

The net/http package documents everything past this — routing, middleware, timeouts — at pkg.go.dev/net/http.


Next steps

Was this guide helpful?