You know how to build an API in Node. You’ve done it with Express, maybe Fastify, maybe even raw http.createServer. Go is different in ways that matter and similar in ways that help.
This go api tutorial won’t waste your time explaining what a REST API is. You already know. Instead, you’ll build one — a bookmarks API with CRUD endpoints, JSON handling, and a SQLite database — using nothing but Go’s standard library. No frameworks. No ORMs.
By the end, you’ll have a working API and a mental model for how Go code maps to the JavaScript patterns you already think in.
What You Need Before You Start
Go 1.22 or later. That version added proper routing to the standard library, which means you no longer need a third-party router for method-based matching and path parameters.
Install Go from go.dev/dl. Verify it works:
go version
# go version go1.22.0 (or later)
Create your project:
mkdir bookmarks-api && cd bookmarks-api
go mod init bookmarks-api
That go mod init is the equivalent of npm init. It creates a go.mod file that tracks your module name and dependencies. Unlike package.json, it doesn’t have a scripts section or metadata fields. It does one thing.
If you’re coming from the TypeScript world and want to strengthen those fundamentals before branching out, the guide on how to actually use TypeScript covers the patterns that transfer well.
Go Project Structure vs. Node
In Node, you’d probably scaffold something like this:
src/
routes/
controllers/
models/
middleware/
index.js
package.json
Go doesn’t enforce a project structure. For a small API, flat is fine:
bookmarks-api/
main.go
handlers.go
store.go
go.mod
No src directory. No index.js. The entry point is whatever file has package main and a func main().
Here’s the thing: Go files in the same directory share a package. Every function, type, and variable in handlers.go is visible to main.go without importing anything. This feels wrong at first if you’re used to explicit imports for every file, but it works.
One rule you’ll internalize fast — capitalized names are exported (public), lowercase names are unexported (private to the package). No export keyword. No module.exports. The casing is the access control.
func GetBookmarks() {} // exported — other packages can call this
func getBookmarks() {} // unexported — only this package can call this
Your First HTTP Server in Go
In Express, you’d write:
const express = require('express');
const app = express();
app.listen(3000, () => console.log('running'));
In Go:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
fmt.Println("listening on :8080")
http.ListenAndServe(":8080", mux)
}
Save that as main.go, run go run ., and hit localhost:8080/health. You’ve got a server.
A few things to notice.
There’s no app object. The ServeMux is a router. You register routes on it, then pass it to the server. The w http.ResponseWriter is where you write your response — think res in Express. The r *http.Request is the incoming request — think req.
The "GET /health" pattern is new in Go 1.22. Before that, you couldn’t specify the HTTP method in the route pattern. You’d register "/health" and then check r.Method inside the handler yourself. That’s gone now.
Run this file:
go run .
No compile step you need to think about. go run compiles and executes. In production you’d use go build to create a single binary, but for development, go run is your node main.js.
Defining Your Data Model
In JavaScript, your bookmark might be a plain object or a TypeScript interface:
interface Bookmark {
id: string;
url: string;
title: string;
tags: string[];
createdAt: string;
}
In Go, you use a struct:
type Bookmark struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
}
Those backtick annotations — `json:"id"` — are struct tags. They tell Go’s JSON encoder what keys to use when converting to and from JSON. Without them, your API would return {"ID":"...","URL":"..."} with uppercase keys. Not what you want.
Structs aren’t classes. There are no constructors, no inheritance, no this. A struct is data. You attach behavior to it with methods, but the data and behavior are separate concerns. This will feel clean once you stop reaching for class.
Create a file called store.go:
package main
import (
"sync"
)
type Bookmark struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
}
type Store struct {
mu sync.RWMutex
bookmarks map[string]Bookmark
}
func NewStore() *Store {
return &Store{
bookmarks: make(map[string]Bookmark),
}
}
That sync.RWMutex is Go’s answer to a problem you’ve never had to think about in Node. JavaScript is single-threaded. Go is not.
Multiple requests can hit your handlers concurrently, and if two goroutines write to the same map at the same time, your program crashes. The mutex prevents that.
Think of it as a lock. mu.Lock() means “I’m writing, everyone else wait.” mu.RLock() means “I’m reading, other readers can proceed but writers must wait.”
Building the CRUD Handlers
Here’s where Go starts to click. Each handler is a function with the same signature: func(w http.ResponseWriter, r *http.Request).
Add a file called handlers.go:
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func (s *Store) handleListBookmarks(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
bookmarks := make([]Bookmark, 0, len(s.bookmarks))
for _, b := range s.bookmarks {
bookmarks = append(bookmarks, b)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bookmarks)
}
A few Go-isms to unpack.
defer s.mu.RUnlock() — the defer keyword schedules a function call to run when the enclosing function returns. It’s a cleanup mechanism. You lock at the top, defer the unlock, and no matter how the function exits — normal return, early return, panic — the lock gets released. There’s no finally block in Go. defer replaces it.
json.NewEncoder(w).Encode(bookmarks) — this streams JSON directly to the response writer. It’s the equivalent of res.json(bookmarks) in Express, but explicit. Go won’t set Content-Type for you. You do it yourself.
Now the create handler:
func (s *Store) handleCreateBookmark(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if input.URL == "" || input.Title == "" {
http.Error(w, "url and title are required", http.StatusBadRequest)
return
}
id := fmt.Sprintf("bk_%d", time.Now().UnixNano())
bookmark := Bookmark{
ID: id,
URL: input.URL,
Title: input.Title,
Tags: input.Tags,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
s.mu.Lock()
s.bookmarks[id] = bookmark
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(bookmark)
}
Notice the anonymous struct for input. You don’t need to define a named type for every piece of data. If it’s only used in one place, an inline struct is fine.
This is the equivalent of destructuring a request body in JS without creating a separate interface.
The &input with the ampersand passes a pointer. Go’s JSON decoder needs to write into the struct, so it needs the memory address, not a copy. In JS everything is a reference. In Go you choose.
Error handling is the part that will feel verbose. There’s no try/catch. Every function that can fail returns an error as its last return value, and you check it immediately.
This is intentional. Go makes error paths visible instead of hiding them behind exception bubbling. You’ll resist it for about a week, then appreciate it.
Now the get-by-ID and delete handlers:
func (s *Store) handleGetBookmark(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
s.mu.RLock()
bookmark, exists := s.bookmarks[id]
s.mu.RUnlock()
if !exists {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bookmark)
}
func (s *Store) handleDeleteBookmark(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
s.mu.Lock()
_, exists := s.bookmarks[id]
if !exists {
s.mu.Unlock()
http.Error(w, "not found", http.StatusNotFound)
return
}
delete(s.bookmarks, id)
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
r.PathValue("id") extracts the path parameter. That’s Go 1.22’s equivalent of req.params.id in Express. Before 1.22, you’d either parse the URL yourself or use a third-party router like chi or gorilla/mux. The standard library now covers this.
The two-value map lookup — bookmark, exists := s.bookmarks[id] — is how Go handles “key might not exist.” No undefined. No null. The second value is a boolean. Clean.
Wiring Up Routes
Update main.go to register all the handlers:
package main
import (
"fmt"
"net/http"
)
func main() {
store := NewStore()
mux := http.NewServeMux()
mux.HandleFunc("GET /bookmarks", store.handleListBookmarks)
mux.HandleFunc("POST /bookmarks", store.handleCreateBookmark)
mux.HandleFunc("GET /bookmarks/{id}", store.handleGetBookmark)
mux.HandleFunc("DELETE /bookmarks/{id}", store.handleDeleteBookmark)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
fmt.Println("listening on :8080")
http.ListenAndServe(":8080", mux)
}
Compare that to Express:
app.get('/bookmarks', listBookmarks);
app.post('/bookmarks', createBookmark);
app.get('/bookmarks/:id', getBookmark);
app.delete('/bookmarks/:id', deleteBookmark);
The shape is the same. The syntax is different. Go’s {id} wildcard pattern does what Express’s :id does. The method is part of the pattern string instead of being a separate function.
Run it:
go run .
Test with curl:
# Create a bookmark
curl -X POST http://localhost:8080/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://go.dev","title":"Go docs","tags":["go","docs"]}'
# List all bookmarks
curl http://localhost:8080/bookmarks
# Get one bookmark (use the ID from the create response)
curl http://localhost:8080/bookmarks/bk_1234567890
# Delete it
curl -X DELETE http://localhost:8080/bookmarks/bk_1234567890
You now have a working REST API in Go. No dependencies outside the standard library.
Adding Middleware
In Express, middleware is a function that sits between the request and your handler:
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
Go doesn’t have built-in middleware chaining, but the pattern is straightforward. A middleware is a function that takes a handler and returns a handler:
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s\n", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
Apply it by wrapping your mux:
func main() {
store := NewStore()
mux := http.NewServeMux()
// ... register routes ...
handler := logging(mux)
fmt.Println("listening on :8080")
http.ListenAndServe(":8080", handler)
}
No next() callback. You call next.ServeHTTP(w, r) to pass control to the next handler. The mental model is the same — intercept, do something, pass along — but the mechanism is function wrapping instead of a middleware stack.
You can chain multiple middleware by nesting:
handler := logging(cors(mux))
The inner middleware runs first. Same direction as Express, different syntax.
Here’s a CORS middleware that’s actually useful:
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
Connecting a Real Database
An in-memory map is fine for learning. It’s not fine for production. Let’s add SQLite.
Install the driver:
go get github.com/mattn/go-sqlite3
This is npm install equivalent. It downloads the package and adds it to your go.mod. One difference — Go doesn’t have a node_modules folder in your project. Dependencies live in a global module cache at $GOPATH/pkg/mod.
If you’re weighing database options for your project, the breakdown on SQL vs. NoSQL covers when each makes sense. For a structured API like this, SQL is the pragmatic answer.
Create a new version of your store that uses SQLite:
package main
import (
"database/sql"
"encoding/json"
"strings"
_ "github.com/mattn/go-sqlite3"
)
type SQLStore struct {
db *sql.DB
}
func NewSQLStore(path string) (*SQLStore, error) {
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, err
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL,
tags TEXT DEFAULT '',
created_at TEXT NOT NULL
)
`)
if err != nil {
return nil, err
}
return &SQLStore{db: db}, nil
}
That blank import — _ "github.com/mattn/go-sqlite3" — looks weird. The underscore means “import for side effects only.” The sqlite3 package registers itself with Go’s database/sql system when imported. You never call it directly. This pattern is common in Go database drivers.
Now implement the methods on SQLStore:
func (s *SQLStore) ListBookmarks() ([]Bookmark, error) {
rows, err := s.db.Query("SELECT id, url, title, tags, created_at FROM bookmarks")
if err != nil {
return nil, err
}
defer rows.Close()
var bookmarks []Bookmark
for rows.Next() {
var b Bookmark
var tags string
if err := rows.Scan(&b.ID, &b.URL, &b.Title, &tags, &b.CreatedAt); err != nil {
return nil, err
}
if tags != "" {
b.Tags = strings.Split(tags, ",")
}
bookmarks = append(bookmarks, b)
}
return bookmarks, nil
}
func (s *SQLStore) CreateBookmark(b Bookmark) error {
tags := strings.Join(b.Tags, ",")
_, err := s.db.Exec(
"INSERT INTO bookmarks (id, url, title, tags, created_at) VALUES (?, ?, ?, ?, ?)",
b.ID, b.URL, b.Title, tags, b.CreatedAt,
)
return err
}
func (s *SQLStore) GetBookmark(id string) (Bookmark, error) {
var b Bookmark
var tags string
err := s.db.QueryRow(
"SELECT id, url, title, tags, created_at FROM bookmarks WHERE id = ?", id,
).Scan(&b.ID, &b.URL, &b.Title, &tags, &b.CreatedAt)
if err != nil {
return b, err
}
if tags != "" {
b.Tags = strings.Split(tags, ",")
}
return b, nil
}
func (s *SQLStore) DeleteBookmark(id string) error {
result, err := s.db.Exec("DELETE FROM bookmarks WHERE id = ?", id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
The database/sql package is Go’s equivalent of a database abstraction layer. It’s not an ORM. You write SQL. You scan results into variables. It’s manual, and that’s the point — you always know what query is running.
rows.Scan(&b.ID, &b.URL, ...) reads columns from the result set into your struct fields. The order must match your SELECT column order. There’s no automatic mapping by column name. This is verbose. It’s also impossible to misunderstand.
Update your handlers to use SQLStore and update main.go:
func main() {
store, err := NewSQLStore("bookmarks.db")
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /bookmarks", func(w http.ResponseWriter, r *http.Request) {
bookmarks, err := store.ListBookmarks()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bookmarks)
})
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
var input struct {
URL string `json:"url"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if input.URL == "" || input.Title == "" {
http.Error(w, "url and title required", http.StatusBadRequest)
return
}
id := fmt.Sprintf("bk_%d", time.Now().UnixNano())
bookmark := Bookmark{
ID: id,
URL: input.URL,
Title: input.Title,
Tags: input.Tags,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := store.CreateBookmark(bookmark); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(bookmark)
})
// GET and DELETE handlers follow the same pattern
handler := logging(cors(mux))
fmt.Println("listening on :8080")
http.ListenAndServe(":8080", handler)
}
Add the necessary imports at the top — "encoding/json", "fmt", "net/http", "time". Go won’t compile if you import something you don’t use, and it won’t compile if you use something you don’t import. No dead imports. No mystery dependencies.
The Go Patterns That Replace Your JS Instincts
After building this API, you’ve hit several spots where Go does things differently. Here’s a quick reference for the concepts that tripped me up coming from JavaScript.
Async/await vs. goroutines. In Node, I/O is async by default and you await it. In Go, I/O blocks the goroutine but other goroutines keep running. You don’t need async/await because Go’s scheduler handles concurrency behind the scenes. Your code reads top-to-bottom like synchronous code, but it’s concurrent.
npm vs. go modules. go get installs packages. go mod tidy cleans up unused ones (like npm prune). go.sum is your package-lock.json. The dependency tree is flatter because Go compiles everything statically.
Error handling vs. try/catch. Every fallible call returns (result, error). You check the error immediately. There’s no throwing, no catching, no unwinding. It’s verbose. It’s also the reason Go codebases have fewer surprise runtime crashes.
Interfaces. Go interfaces are implicit. A type satisfies an interface by having the right methods — you don’t declare implements. If your SQLStore has a ListBookmarks() method that matches an interface, it satisfies that interface automatically. No ceremony.
type BookmarkStore interface {
ListBookmarks() ([]Bookmark, error)
CreateBookmark(Bookmark) error
GetBookmark(string) (Bookmark, error)
DeleteBookmark(string) error
}
Both your in-memory Store and SQLStore can satisfy this interface without knowing about it. That’s how you swap implementations in Go — define the interface where it’s consumed, not where it’s produced. The opposite of Java. Closer to how duck typing works in JavaScript, but checked at compile time.
No generics anxiety. Go has generics now (since 1.18), but you don’t need them for API work. The standard library handles most of the heavy lifting. Don’t reach for generics until you’re writing library code with repeated type patterns.
Deploying What You Built
One of Go’s real advantages: your API compiles to a single binary.
go build -o bookmarks-api .
That produces a bookmarks-api executable. No node_modules. No runtime dependency. Copy it to a server and run it. That’s the deployment.
For a Docker container:
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 go build -o api .
FROM debian:bookworm-slim
COPY --from=builder /app/api /api
COPY --from=builder /app/bookmarks.db /bookmarks.db
EXPOSE 8080
CMD ["/api"]
The multi-stage build gives you a container that’s about 80MB instead of the 1GB+ you’d get with a Node image that includes the runtime and node_modules. In production, that difference matters for startup time and memory.
Cross-compilation is another Go strength. Building for Linux from your Mac:
GOOS=linux GOARCH=amd64 go build -o bookmarks-api .
Try doing that with a Node project. I’ll wait.
Where to Go From Here
You built a working Go API tutorial project from scratch. It handles routing, JSON, CRUD operations, middleware, and a database. That’s a real foundation.
The patterns transfer. If you can build an Express API, you can build a Go API. The language is different, the standard library is different, but the architecture is the same: route requests to handlers, validate input, talk to a database, return responses.
The tradeoff is verbosity for clarity. More lines of code. Fewer surprises at runtime. No undefined is not a function at 3am.
Start with small services. A webhook handler. A data processing API. Something where Go’s speed and deployment story make the overhead of learning worth it. You don’t need to rewrite your Next.js app in Go. But the next time you need a backend service that’s fast, deploys clean, and doesn’t need a package manager ecosystem to function — now you have another option.