[] markdown.page

Designing a REST API

A practical walkthrough of building a clean REST API, with examples in multiple languages.

The schema

We're building a bookmarks API. Each bookmark has an ID, a URL, a title, and tags.

{
  "id": "bk_7a3x9k",
  "url": "https://example.com/article",
  "title": "Interesting Article",
  "tags": ["reading", "tech"],
  "created_at": "2026-03-25T10:30:00Z"
}

The server

A minimal Express server in TypeScript:

import express from "express";

interface Bookmark {
  id: string;
  url: string;
  title: string;
  tags: string[];
  created_at: string;
}

const app = express();
app.use(express.json());

const bookmarks = new Map<string, Bookmark>();

app.get("/api/bookmarks", (req, res) => {
  const tag = req.query.tag as string | undefined;
  let results = [...bookmarks.values()];

  if (tag) {
    results = results.filter((b) => b.tags.includes(tag));
  }

  res.json({ bookmarks: results, total: results.length });
});

app.post("/api/bookmarks", (req, res) => {
  const { url, title, tags = [] } = req.body;

  if (!url || !title) {
    return res.status(400).json({ error: "url and title are required" });
  }

  const bookmark: Bookmark = {
    id: `bk_${crypto.randomUUID().slice(0, 6)}`,
    url,
    title,
    tags,
    created_at: new Date().toISOString(),
  };

  bookmarks.set(bookmark.id, bookmark);
  res.status(201).json(bookmark);
});

app.delete("/api/bookmarks/:id", (req, res) => {
  const deleted = bookmarks.delete(req.params.id);
  if (!deleted) return res.status(404).json({ error: "not found" });
  res.status(204).end();
});

app.listen(3000, () => console.log("listening on :3000"));

Consuming it with Python

import requests

API = "http://localhost:3000/api/bookmarks"

# Create a bookmark
response = requests.post(API, json={
    "url": "https://markdown.page",
    "title": "Markdown Page",
    "tags": ["tools", "writing"]
})

bookmark = response.json()
print(f"Created: {bookmark['id']}{bookmark['title']}")

# List all bookmarks tagged 'tools'
response = requests.get(API, params={"tag": "tools"})
data = response.json()

for b in data["bookmarks"]:
    print(f"  [{b['id']}] {b['title']}{b['url']}")

# Delete it
requests.delete(f"{API}/{bookmark['id']}")
print(f"Deleted: {bookmark['id']}")

A Go client

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

type Bookmark struct {
	ID        string   `json:"id"`
	URL       string   `json:"url"`
	Title     string   `json:"title"`
	Tags      []string `json:"tags"`
	CreatedAt string   `json:"created_at"`
}

func main() {
	body, _ := json.Marshal(map[string]interface{}{
		"url":   "https://go.dev",
		"title": "The Go Programming Language",
		"tags":  []string{"languages", "go"},
	})

	resp, err := http.Post(
		"http://localhost:3000/api/bookmarks",
		"application/json",
		bytes.NewReader(body),
	)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var bm Bookmark
	json.NewDecoder(resp.Body).Decode(&bm)
	fmt.Printf("Created: %s — %s\n", bm.ID, bm.Title)
}

Testing with curl

# Create
curl -s -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","title":"Example","tags":["test"]}' \
  | jq .

# List
curl -s http://localhost:3000/api/bookmarks | jq '.bookmarks[] | .title'

# Filter by tag
curl -s "http://localhost:3000/api/bookmarks?tag=test" | jq .

# Delete
curl -s -X DELETE http://localhost:3000/api/bookmarks/bk_abc123

SQL schema

If you want to persist bookmarks, here's a PostgreSQL schema:

CREATE TABLE bookmarks (
    id         TEXT PRIMARY KEY,
    url        TEXT NOT NULL,
    title      TEXT NOT NULL,
    tags       TEXT[] DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_bookmarks_tags ON bookmarks USING GIN (tags);

-- Find bookmarks by tag
SELECT id, url, title, created_at
FROM bookmarks
WHERE 'tools' = ANY(tags)
ORDER BY created_at DESC
LIMIT 20;

Nginx reverse proxy

upstream api {
    server 127.0.0.1:3000;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate     /etc/ssl/certs/api.pem;
    ssl_certificate_key /etc/ssl/private/api.key;

    location /api/ {
        proxy_pass http://api;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The takeaway

A good API is boring. Predictable URLs, consistent error shapes, standard HTTP methods. The code above isn't clever — it's clear. That's the point.