Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
16 minLesson 30 of 40
Backend with Node.js

REST API Design Principles

REST API Design Principles

A REST API is only as good as its design. The technical implementation is secondary — what matters is whether other developers (or your future self) can use it without confusion. Good API design is about predictability, consistency, and being explicit about intent.

REST Fundamentals

REST (Representational State Transfer) uses HTTP verbs and URLs to represent operations on resources:

MethodURLAction
GET/api/coursesList all courses
GET/api/courses/42Get course #42
POST/api/coursesCreate a new course
PUT/api/courses/42Replace course #42 entirely
PATCH/api/courses/42Update specific fields of course #42
DELETE/api/courses/42Delete course #42

The URL describes the resource. The method describes the action. Clear separation of concerns.

URL Naming Conventions

Use nouns, not verbs. The HTTP method is already the verb:

❌ Bad — verbs in URLs
GET  /getCourses
POST /createCourse
DELETE /deleteCourse/42

✅ Good — nouns in URLs
GET    /api/courses
POST   /api/courses
DELETE /api/courses/42

Use plural nouns consistently:

/api/courses      not /api/course
/api/users        not /api/user
/api/enrollments  not /api/enrollment

Nested resources express relationships:

GET /api/courses/42/lessons         → lessons for course 42
GET /api/courses/42/lessons/5       → lesson 5 of course 42
POST /api/courses/42/lessons        → create a lesson in course 42
DELETE /api/courses/42/lessons/5    → delete lesson 5 of course 42

Keep nesting shallow — more than two levels deep becomes hard to maintain.

HTTP Status Codes

Use the right status code. It communicates the result without the caller having to parse the response body:

2xx — Success
200 OK           → Successful GET, PATCH, PUT
201 Created      → Successful POST (include Location header with new resource URL)
204 No Content   → Successful DELETE (no body)

3xx — Redirect
301 Moved Permanently
302 Found (temporary redirect)

4xx — Client Error (the caller did something wrong)
400 Bad Request  → Invalid input, malformed JSON, missing required fields
401 Unauthorized → Not authenticated (no token / invalid token)
403 Forbidden    → Authenticated but not allowed to do this
404 Not Found    → Resource doesn't exist
409 Conflict     → Duplicate entry (email already registered, slug already exists)
422 Unprocessable → Validation failed (valid JSON, but field values are wrong)
429 Too Many Requests → Rate limited

5xx — Server Error (something went wrong on your end)
500 Internal Server Error → Unexpected crash
503 Service Unavailable   → Database down, maintenance mode

Consistent Error Responses

Always return errors in the same format:

// src/lib/errors.ts
export function errorResponse(message: string, statusCode: number, details?: Record<string, string>) {
    return {
        error: {
            message,
            statusCode,
            ...(details && { details }),
        }
    };
}
// 400 Bad Request
{
    "error": {
        "message": "Validation failed",
        "statusCode": 400,
        "details": {
            "email": "Valid email required",
            "password": "Must be at least 8 characters"
        }
    }
}

// 404 Not Found
{
    "error": {
        "message": "Course not found",
        "statusCode": 404
    }
}

Don't leak internal error messages in production — "Column 'user_id' cannot be null" tells attackers about your schema. Return generic messages for 5xx errors and log the details server-side.

Pagination

Never return thousands of records without pagination:

// GET /api/courses?page=2&limit=20
app.get("/api/courses", async (req, res) => {
    const page = Math.max(1, parseInt(req.query.page as string) || 1);
    const limit = Math.min(100, parseInt(req.query.limit as string) || 20);
    const skip = (page - 1) * limit;
    
    const [courses, total] = await Promise.all([
        db.course.findMany({ skip, take: limit }),
        db.course.count(),
    ]);
    
    res.json({
        data: courses,
        pagination: {
            page,
            limit,
            total,
            pages: Math.ceil(total / limit),
            hasNext: page * limit < total,
            hasPrev: page > 1,
        }
    });
});

Response:

{
    "data": [ ... ],
    "pagination": {
        "page": 2,
        "limit": 20,
        "total": 147,
        "pages": 8,
        "hasNext": true,
        "hasPrev": true
    }
}

Filtering and Sorting

// GET /api/courses?category=react&sort=price&order=asc&search=hooks
app.get("/api/courses", async (req, res) => {
    const { category, search, sort = "createdAt", order = "desc" } = req.query;
    
    const where: any = {};
    if (category) where.category = category;
    if (search) where.title = { contains: search, mode: "insensitive" };
    
    const allowedSorts = ["price", "createdAt", "title", "rating"];
    const sortField = allowedSorts.includes(sort as string) ? sort : "createdAt";
    
    const courses = await db.course.findMany({
        where,
        orderBy: { [sortField as string]: order === "asc" ? "asc" : "desc" },
    });
    
    res.json({ data: courses });
});

Whitelist allowed sort fields — never pass user-controlled values directly to orderBy. That's a potential injection vector.

Versioning

Version your API so you can introduce breaking changes without breaking existing clients:

/api/v1/courses     → Current stable version
/api/v2/courses     → New version with breaking changes
import v1Router from "./routes/v1";
import v2Router from "./routes/v2";

app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);

Commit to v1 forever. When you need to break something, release v2 and give clients a migration window before deprecating v1.

Request Validation

Validate all input before touching your database. Use zod for TypeScript-first validation:

npm install zod
import { z } from "zod";

const CreateCourseSchema = z.object({
    title: z.string().min(3).max(200),
    description: z.string().min(10).max(2000),
    price: z.number().min(0).max(10000),
    category: z.enum(["react", "python", "javascript", "ai"]),
});

app.post("/api/courses", async (req, res) => {
    const result = CreateCourseSchema.safeParse(req.body);
    
    if (!result.success) {
        const details = Object.fromEntries(
            result.error.issues.map(i => [i.path.join("."), i.message])
        );
        return res.status(400).json({
            error: { message: "Validation failed", statusCode: 400, details }
        });
    }
    
    const course = await db.course.create({ data: result.data });
    res.status(201).json(course);
});

Rate Limiting

Protect your API from abuse:

npm install express-rate-limit
import rateLimit from "express-rate-limit";

const apiLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,  // 15 minutes
    max: 100,                   // 100 requests per window
    standardHeaders: true,
    message: { error: "Too many requests, please try again later." },
});

const authLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,
    max: 5,   // Stricter limit on login attempts
    message: { error: "Too many login attempts." },
});

app.use("/api/", apiLimiter);
app.use("/api/auth/login", authLimiter);

Idempotency

Design operations with idempotency in mind:

  • GET is always idempotent — same request, same result
  • DELETE is idempotent — deleting something twice should both return 2xx (or the second returns 404)
  • PUT is idempotent — replacing a resource with the same data twice gives the same result
  • POST is not idempotent — calling it twice creates two records

For operations like payments that must not run twice, accept an idempotency key:

app.post("/api/payments", async (req, res) => {
    const idempotencyKey = req.headers["idempotency-key"] as string;
    
    if (idempotencyKey) {
        const existing = await db.payment.findUnique({ where: { idempotencyKey } });
        if (existing) return res.json(existing);   // Return cached result
    }
    
    const payment = await processPayment(req.body);
    
    if (idempotencyKey) {
        await db.payment.update({ where: { id: payment.id }, data: { idempotencyKey } });
    }
    
    res.status(201).json(payment);
});

Next lesson: File uploads with Node.js — handling images, documents, and other uploaded files.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!