Skip to content
← System Design · beginner · 20 min · 02 / 26

REST API Design

Design and build a complete REST API with proper HTTP semantics, validation, pagination, and filtering.

RESTHTTP methodspaginationvalidationstatus codes

What is REST?

REST (Representational State Transfer) is an architectural style for designing APIs. It maps CRUD operations to HTTP methods and uses URLs to represent resources. The key constraint: every request must contain all information needed to process it — the server stores no client session state.

Think of it like a library catalog system.

Real-World Analogy

Like a library catalog — each book (resource) has a unique call number (URL). You can check out (GET), donate (POST), update info (PUT), or remove (DELETE). The catalog doesn’t remember you between visits — bring your card each time.

Each book (resource) has a unique call number (URL). You can check books out (GET), add new ones (POST), update information (PUT), or remove them (DELETE). The catalog doesn’t remember who you are between visits — you bring your library card (auth token) each time.

REST Resource Mapping
GET /users
--->
List users
POST /users
--->
Create user
GET /users/42
--->
Get user 42
PUT /users/42
--->
Update user 42
DELETE /users/42
--->
Delete user 42

HTTP Status Codes That Matter

CodeMeaningWhen to Use
200OKSuccessful GET, PUT
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE
400Bad RequestInvalid input, missing required fields
401UnauthorizedMissing or invalid auth
403ForbiddenValid auth but insufficient permissions
404Not FoundResource doesn’t exist
409ConflictDuplicate resource, version conflict
422UnprocessableValid JSON but fails business rules
429Too Many RequestsRate limited
500Internal ErrorUnhandled server error

Complete REST API with Pagination and Filtering

This is a production-grade REST API for a blog platform. It includes cursor-based pagination, field filtering, input validation, and proper error formatting.

import http from "node:http";
import crypto from "node:crypto";

// --- Domain Types ---
interface Post {
  id: string;
  slug: string;
  title: string;
  body: string;
  authorId: string;
  tags: string[];
  published: boolean;
  createdAt: string;
  updatedAt: string;
}

interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    cursor: string | null;
    hasMore: boolean;
    total: number;
  };
}

interface ValidationError {
  field: string;
  message: string;
}

// --- Store ---
const posts = new Map<string, Post>();

// Seed some data
for (let i = 1; i <= 50; i++) {
  const id = crypto.randomUUID();
  posts.set(id, {
    id,
    slug: `post-${i}`,
    title: `Blog Post ${i}`,
    body: `Content of post ${i}. This covers various system design topics.`,
    authorId: `user-${(i % 5) + 1}`,
    tags: i % 2 === 0 ? ["system-design", "backend"] : ["frontend", "react"],
    published: i % 3 !== 0,
    createdAt: new Date(Date.now() - i * 86400000).toISOString(),
    updatedAt: new Date(Date.now() - i * 43200000).toISOString(),
  });
}

// --- Validation ---
function validateCreatePost(body: unknown): ValidationError[] {
  const errors: ValidationError[] = [];
  const data = body as Record<string, unknown>;

  if (!data || typeof data !== "object") {
    return [{ field: "body", message: "Request body must be a JSON object" }];
  }

  if (!data.title || typeof data.title !== "string" || data.title.trim().length < 3) {
    errors.push({ field: "title", message: "Must be at least 3 characters" });
  }
  if (typeof data.title === "string" && data.title.length > 200) {
    errors.push({ field: "title", message: "Must be at most 200 characters" });
  }
  if (!data.body || typeof data.body !== "string" || data.body.trim().length < 10) {
    errors.push({ field: "body", message: "Must be at least 10 characters" });
  }
  if (!data.authorId || typeof data.authorId !== "string") {
    errors.push({ field: "authorId", message: "Required" });
  }
  if (data.tags && !Array.isArray(data.tags)) {
    errors.push({ field: "tags", message: "Must be an array of strings" });
  }

  // Check slug uniqueness
  if (data.title && typeof data.title === "string") {
    const slug = slugify(data.title);
    const existing = Array.from(posts.values()).find((p) => p.slug === slug);
    if (existing) {
      errors.push({ field: "title", message: "A post with this title already exists" });
    }
  }

  return errors;
}

function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "");
}

// --- Helpers ---
function json(res: http.ServerResponse, status: number, data: unknown): void {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(data));
}

function parseBody(req: http.IncomingMessage): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on("data", (c: Buffer) => chunks.push(c));
    req.on("end", () => {
      try {
        resolve(JSON.parse(Buffer.concat(chunks).toString()));
      } catch {
        reject(new Error("Invalid JSON"));
      }
    });
  });
}

// --- Handlers ---
function listPosts(req: http.IncomingMessage, res: http.ServerResponse): void {
  const url = new URL(req.url!, `http://${req.headers.host}`);

  // Parse query params
  const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
  const cursor = url.searchParams.get("cursor");
  const tag = url.searchParams.get("tag");
  const authorId = url.searchParams.get("author_id");
  const published = url.searchParams.get("published");
  const fields = url.searchParams.get("fields")?.split(",");

  // Filter
  let items = Array.from(posts.values());

  if (tag) items = items.filter((p) => p.tags.includes(tag));
  if (authorId) items = items.filter((p) => p.authorId === authorId);
  if (published !== null && published !== undefined) {
    items = items.filter((p) => p.published === (published === "true"));
  }

  // Sort by createdAt descending
  items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));

  const total = items.length;

  // Cursor-based pagination
  if (cursor) {
    const cursorIndex = items.findIndex((p) => p.id === cursor);
    if (cursorIndex >= 0) {
      items = items.slice(cursorIndex + 1);
    }
  }

  const page = items.slice(0, limit);
  const hasMore = items.length > limit;
  const nextCursor = hasMore ? page[page.length - 1]?.id || null : null;

  // Field selection
  let responseData: unknown[] = page;
  if (fields && fields.length > 0) {
    responseData = page.map((p) => {
      const selected: Record<string, unknown> = {};
      for (const f of fields) {
        if (f in p) selected[f] = (p as Record<string, unknown>)[f];
      }
      return selected;
    });
  }

  const response: PaginatedResponse<unknown> = {
    data: responseData,
    pagination: { cursor: nextCursor, hasMore, total },
  };

  json(res, 200, response);
}

async function createPost(
  req: http.IncomingMessage,
  res: http.ServerResponse
): Promise<void> {
  const body = await parseBody(req);
  const errors = validateCreatePost(body);

  if (errors.length > 0) {
    json(res, 422, { errors, status: 422 });
    return;
  }

  const data = body as { title: string; body: string; authorId: string; tags?: string[] };
  const id = crypto.randomUUID();
  const now = new Date().toISOString();

  const post: Post = {
    id,
    slug: slugify(data.title),
    title: data.title.trim(),
    body: data.body.trim(),
    authorId: data.authorId,
    tags: data.tags || [],
    published: false,
    createdAt: now,
    updatedAt: now,
  };

  posts.set(id, post);

  // Return 201 with Location header
  res.writeHead(201, {
    "Content-Type": "application/json",
    Location: `/api/posts/${post.slug}`,
  });
  res.end(JSON.stringify({ data: post, status: 201 }));
}

function getPost(res: http.ServerResponse, identifier: string): void {
  // Support lookup by ID or slug
  const post =
    posts.get(identifier) ||
    Array.from(posts.values()).find((p) => p.slug === identifier);

  if (!post) {
    json(res, 404, { error: "Post not found", status: 404 });
    return;
  }
  json(res, 200, { data: post, status: 200 });
}

async function updatePost(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  id: string
): Promise<void> {
  const post = posts.get(id);
  if (!post) {
    json(res, 404, { error: "Post not found", status: 404 });
    return;
  }

  const body = (await parseBody(req)) as Partial<Post>;
  if (body.title) post.title = body.title.trim();
  if (body.body) post.body = body.body.trim();
  if (body.tags) post.tags = body.tags;
  if (body.published !== undefined) post.published = body.published;
  post.updatedAt = new Date().toISOString();

  json(res, 200, { data: post, status: 200 });
}

function deletePost(res: http.ServerResponse, id: string): void {
  if (!posts.delete(id)) {
    json(res, 404, { error: "Post not found", status: 404 });
    return;
  }
  res.writeHead(204);
  res.end();
}

// --- Router ---
const server = http.createServer(async (req, res) => {
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
  const path = url.pathname;
  const method = req.method!;

  try {
    const match = path.match(/^\/api\/posts\/(.+)$/);

    if (path === "/api/posts" && method === "GET") return listPosts(req, res);
    if (path === "/api/posts" && method === "POST") return await createPost(req, res);
    if (match && method === "GET") return getPost(res, match[1]);
    if (match && method === "PUT") return await updatePost(req, res, match[1]);
    if (match && method === "DELETE") return deletePost(res, match[1]);

    json(res, 404, { error: "Not found", status: 404 });
  } catch (err) {
    console.error(err);
    json(res, 500, { error: "Internal server error", status: 500 });
  }
});

server.listen(3000, () => console.log("API running on http://localhost:3000"));
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
)

// --- Domain Types ---
type Post struct {
	ID        string   `json:"id"`
	Slug      string   `json:"slug"`
	Title     string   `json:"title"`
	Body      string   `json:"body"`
	AuthorID  string   `json:"authorId"`
	Tags      []string `json:"tags"`
	Published bool     `json:"published"`
	CreatedAt string   `json:"createdAt"`
	UpdatedAt string   `json:"updatedAt"`
}

type PaginatedResponse struct {
	Data       interface{} `json:"data"`
	Pagination Pagination  `json:"pagination"`
}

type Pagination struct {
	Cursor  *string `json:"cursor"`
	HasMore bool    `json:"hasMore"`
	Total   int     `json:"total"`
}

type ValidationError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

// --- Store ---
type PostStore struct {
	mu    sync.RWMutex
	posts map[string]Post
}

func NewPostStore() *PostStore {
	s := &PostStore{posts: make(map[string]Post)}
	// Seed data
	for i := 1; i <= 50; i++ {
		id := uuid.New().String()
		tags := []string{"frontend", "react"}
		if i%2 == 0 {
			tags = []string{"system-design", "backend"}
		}
		s.posts[id] = Post{
			ID:        id,
			Slug:      fmt.Sprintf("post-%d", i),
			Title:     fmt.Sprintf("Blog Post %d", i),
			Body:      fmt.Sprintf("Content of post %d about system design.", i),
			AuthorID:  fmt.Sprintf("user-%d", (i%5)+1),
			Tags:      tags,
			Published: i%3 != 0,
			CreatedAt: time.Now().Add(-time.Duration(i) * 24 * time.Hour).UTC().Format(time.RFC3339),
			UpdatedAt: time.Now().Add(-time.Duration(i) * 12 * time.Hour).UTC().Format(time.RFC3339),
		}
	}
	return s
}

// --- Validation ---
func validateCreatePost(data map[string]interface{}, store *PostStore) []ValidationError {
	var errs []ValidationError

	title, _ := data["title"].(string)
	if len(strings.TrimSpace(title)) < 3 {
		errs = append(errs, ValidationError{Field: "title", Message: "Must be at least 3 characters"})
	}
	if len(title) > 200 {
		errs = append(errs, ValidationError{Field: "title", Message: "Must be at most 200 characters"})
	}

	body, _ := data["body"].(string)
	if len(strings.TrimSpace(body)) < 10 {
		errs = append(errs, ValidationError{Field: "body", Message: "Must be at least 10 characters"})
	}

	authorID, _ := data["authorId"].(string)
	if authorID == "" {
		errs = append(errs, ValidationError{Field: "authorId", Message: "Required"})
	}

	// Check slug uniqueness
	if title != "" {
		slug := slugify(title)
		store.mu.RLock()
		for _, p := range store.posts {
			if p.Slug == slug {
				errs = append(errs, ValidationError{Field: "title", Message: "A post with this title already exists"})
				break
			}
		}
		store.mu.RUnlock()
	}

	return errs
}

var slugRegex = regexp.MustCompile(`[^a-z0-9]+`)

func slugify(text string) string {
	slug := slugRegex.ReplaceAllString(strings.ToLower(text), "-")
	return strings.Trim(slug, "-")
}

// --- Handlers ---
type PostHandler struct {
	store *PostStore
}

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

func (h *PostHandler) List(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	limit, _ := strconv.Atoi(q.Get("limit"))
	if limit <= 0 || limit > 100 {
		limit = 20
	}
	cursor := q.Get("cursor")
	tag := q.Get("tag")
	authorID := q.Get("author_id")
	published := q.Get("published")

	h.store.mu.RLock()
	items := make([]Post, 0, len(h.store.posts))
	for _, p := range h.store.posts {
		if tag != "" && !contains(p.Tags, tag) {
			continue
		}
		if authorID != "" && p.AuthorID != authorID {
			continue
		}
		if published != "" {
			pub := published == "true"
			if p.Published != pub {
				continue
			}
		}
		items = append(items, p)
	}
	h.store.mu.RUnlock()

	// Sort by createdAt descending
	sort.Slice(items, func(i, j int) bool {
		return items[i].CreatedAt > items[j].CreatedAt
	})

	total := len(items)

	// Cursor-based pagination
	if cursor != "" {
		idx := -1
		for i, p := range items {
			if p.ID == cursor {
				idx = i
				break
			}
		}
		if idx >= 0 {
			items = items[idx+1:]
		}
	}

	hasMore := len(items) > limit
	if len(items) > limit {
		items = items[:limit]
	}

	var nextCursor *string
	if hasMore && len(items) > 0 {
		c := items[len(items)-1].ID
		nextCursor = &c
	}

	writeJSON(w, http.StatusOK, PaginatedResponse{
		Data:       items,
		Pagination: Pagination{Cursor: nextCursor, HasMore: hasMore, Total: total},
	})
}

func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
	var data map[string]interface{}
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&data); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "Invalid JSON", "status": 400})
		return
	}

	errs := validateCreatePost(data, h.store)
	if len(errs) > 0 {
		writeJSON(w, 422, map[string]interface{}{"errors": errs, "status": 422})
		return
	}

	now := time.Now().UTC().Format(time.RFC3339)
	title := strings.TrimSpace(data["title"].(string))

	tags := []string{}
	if t, ok := data["tags"].([]interface{}); ok {
		for _, v := range t {
			if s, ok := v.(string); ok {
				tags = append(tags, s)
			}
		}
	}

	post := Post{
		ID:        uuid.New().String(),
		Slug:      slugify(title),
		Title:     title,
		Body:      strings.TrimSpace(data["body"].(string)),
		AuthorID:  data["authorId"].(string),
		Tags:      tags,
		Published: false,
		CreatedAt: now,
		UpdatedAt: now,
	}

	h.store.mu.Lock()
	h.store.posts[post.ID] = post
	h.store.mu.Unlock()

	w.Header().Set("Location", "/api/posts/"+post.Slug)
	writeJSON(w, http.StatusCreated, map[string]interface{}{"data": post, "status": 201})
}

func (h *PostHandler) Get(w http.ResponseWriter, _ *http.Request, identifier string) {
	h.store.mu.RLock()
	defer h.store.mu.RUnlock()

	// Lookup by ID or slug
	if post, ok := h.store.posts[identifier]; ok {
		writeJSON(w, http.StatusOK, map[string]interface{}{"data": post, "status": 200})
		return
	}
	for _, p := range h.store.posts {
		if p.Slug == identifier {
			writeJSON(w, http.StatusOK, map[string]interface{}{"data": p, "status": 200})
			return
		}
	}
	writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "Post not found", "status": 404})
}

func (h *PostHandler) Delete(w http.ResponseWriter, _ *http.Request, id string) {
	h.store.mu.Lock()
	defer h.store.mu.Unlock()
	if _, ok := h.store.posts[id]; !ok {
		writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "Post not found", "status": 404})
		return
	}
	delete(h.store.posts, id)
	w.WriteHeader(http.StatusNoContent)
}

func contains(slice []string, item string) bool {
	for _, s := range slice {
		if s == item {
			return true
		}
	}
	return false
}

// --- Router ---
func main() {
	store := NewPostStore()
	handler := &PostHandler{store: store}
	postIDPattern := regexp.MustCompile(`^/api/posts/(.+)$`)

	mux := http.NewServeMux()

	mux.HandleFunc("/api/posts", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet:
			handler.List(w, r)
		case http.MethodPost:
			handler.Create(w, r)
		default:
			writeJSON(w, 405, map[string]string{"error": "Method not allowed"})
		}
	})

	mux.HandleFunc("/api/posts/", func(w http.ResponseWriter, r *http.Request) {
		m := postIDPattern.FindStringSubmatch(r.URL.Path)
		if m == nil {
			writeJSON(w, 404, map[string]string{"error": "Not found"})
			return
		}
		id := m[1]
		switch r.Method {
		case http.MethodGet:
			handler.Get(w, r, id)
		case http.MethodDelete:
			handler.Delete(w, r, id)
		default:
			writeJSON(w, 405, map[string]string{"error": "Method not allowed"})
		}
	})

	log.Println("API running on http://localhost:3000")
	log.Fatal(http.ListenAndServe(":3000", mux))
}
Cursor vs Offset Pagination

Offset pagination (?page=5&limit=20) breaks when items are inserted or deleted between requests — users see duplicates or miss items. Cursor-based pagination (?cursor=abc123&limit=20) uses the last item’s ID as a bookmark, making it stable even with concurrent writes. This is why Twitter, Slack, and Facebook all use cursor-based pagination.

Key Takeaways

  • Use nouns for URLs (/posts), not verbs (/getPosts) — let HTTP methods convey the action
  • Return proper status codes — 201 for created, 422 for validation failures, 204 for deletes
  • Cursor-based pagination is more reliable than offset pagination for mutable datasets
  • Always validate at the API boundary and return structured error messages with field names
  • Support both ID and slug lookups — IDs for internal use, slugs for human-readable URLs

Real-World Usage

  • GitHub REST API uses cursor-based pagination with Link headers for navigation
  • Stripe returns structured validation errors with field-level detail, exactly like our implementation
  • Shopify uses both ID and slug for resource lookups, enabling clean URLs for merchants
  • REST works best for CRUD-heavy apps. For real-time or graph-shaped data, consider GraphQL or WebSockets