API Design Best Practices: Building RESTful APIs That Scale

2025-01-20
16 mins read
Backend
API DesignRESTBackendNode.jsBest Practices

Learn how to design robust, scalable REST APIs with proper error handling, versioning, authentication, and documentation.

API Design Best Practices: Building RESTful APIs That Scale

A well-designed API is the backbone of modern applications. In this guide, I'll share the principles and patterns I use to build RESTful APIs that are intuitive, maintainable, and scale gracefully.

Core Principles

1. Resource-Based URLs

✅ Good: Resources as nouns

typescript
1GET    /api/users
2GET    /api/users/123
3POST   /api/users
4PUT    /api/users/123
5DELETE /api/users/123

❌ Bad: Actions as verbs

typescript
1GET    /api/getAllUsers
2GET    /api/getUserById/123
3POST   /api/createUser
4POST   /api/updateUser/123
5POST   /api/deleteUser/123

2. Use HTTP Methods Correctly

typescript
1// GET - Read/Retrieve (Idempotent, Safe)
2app.get('/api/posts/:id', async (req, res) => {
3  const post = await db.post.findUnique({ where: { id: req.params.id } });
4  res.json(post);
5});
6
7// POST - Create (Not Idempotent)
8app.post('/api/posts', async (req, res) => {
9  const post = await db.post.create({ data: req.body });
10  res.status(201).json(post);
11});
12
13// PUT - Update/Replace (Idempotent)
14app.put('/api/posts/:id', async (req, res) => {
15  const post = await db.post.update({
16    where: { id: req.params.id },
17    data: req.body,
18  });
19  res.json(post);
20});
21
22// PATCH - Partial Update (Idempotent)
23app.patch('/api/posts/:id', async (req, res) => {
24  const post = await db.post.update({
25    where: { id: req.params.id },
26    data: req.body, // Only fields provided
27  });
28  res.json(post);
29});
30
31// DELETE - Remove (Idempotent)
32app.delete('/api/posts/:id', async (req, res) => {
33  await db.post.delete({ where: { id: req.params.id } });
34  res.status(204).send();
35});

Error Handling

Consistent Error Format

typescript
1// types/api.ts
2export interface APIError {
3  error: {
4    message: string;
5    code: string;
6    status: number;
7    details?: unknown;
8    timestamp: string;
9  };
10}
typescript
1// middleware/errorHandler.ts
2import { Request, Response, NextFunction } from 'express';
3
4export class AppError extends Error {
5  constructor(
6    public message: string,
7    public statusCode: number,
8    public code: string,
9    public details?: unknown,
10  ) {
11    super(message);
12  }
13}
14
15export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
16  if (err instanceof AppError) {
17    return res.status(err.statusCode).json({
18      error: {
19        message: err.message,
20        code: err.code,
21        status: err.statusCode,
22        details: err.details,
23        timestamp: new Date().toISOString(),
24      },
25    });
26  }
27
28  // Unexpected errors
29  if (process.env.NODE_ENV === 'development') {
30    console.error('Unexpected error:', err);
31  }
32  res.status(500).json({
33    error: {
34      message: 'Internal server error',
35      code: 'INTERNAL_ERROR',
36      status: 500,
37      timestamp: new Date().toISOString(),
38    },
39  });
40}

Standard HTTP Status Codes

typescript
1// Success codes
2200 OK                 // Successful GET, PUT, PATCH
3201 Created            // Successful POST
4204 No Content         // Successful DELETE
5
6// Client error codes
7400 Bad Request        // Invalid request data
8401 Unauthorized       // Missing/invalid auth
9403 Forbidden          // Insufficient permissions
10404 Not Found          // Resource doesn't exist
11409 Conflict           // Conflict with existing data
12422 Unprocessable      // Validation errors
13429 Too Many Requests  // Rate limit exceeded
14
15// Server error codes
16500 Internal Error     // Server error
17502 Bad Gateway        // Upstream service error
18503 Service Unavailable // Temporary downtime
typescript
1// Example: User creation with validation
2app.post('/api/users', async (req, res, next) => {
3  try {
4    // Validate input
5    const { email, password, name } = req.body;
6
7    if (!email || !password) {
8      throw new AppError('Email and password are required', 400, 'VALIDATION_ERROR', {
9        fields: ['email', 'password'],
10      });
11    }
12
13    // Check for existing user
14    const existing = await db.user.findUnique({ where: { email } });
15    if (existing) {
16      throw new AppError('User with this email already exists', 409, 'USER_EXISTS', { email });
17    }
18
19    // Create user
20    const user = await db.user.create({
21      data: { email, password: await hash(password), name },
22    });
23
24    res.status(201).json({
25      data: {
26        id: user.id,
27        email: user.email,
28        name: user.name,
29      },
30    });
31  } catch (error) {
32    next(error);
33  }
34});

Pagination

typescript
1interface PaginationQuery {
2  page?: string;
3  limit?: string;
4  sort?: string;
5  order?: 'asc' | 'desc';
6}
7
8interface PaginatedResponse<T> {
9  data: T[];
10  pagination: {
11    page: number;
12    limit: number;
13    total: number;
14    totalPages: number;
15    hasNext: boolean;
16    hasPrev: boolean;
17  };
18}
19
20app.get('/api/posts', async (req: Request<{}, {}, {}, PaginationQuery>, res) => {
21  const page = parseInt(req.query.page || '1');
22  const limit = parseInt(req.query.limit || '20');
23  const sort = req.query.sort || 'createdAt';
24  const order = req.query.order || 'desc';
25
26  const skip = (page - 1) * limit;
27
28  const [posts, total] = await Promise.all([
29    db.post.findMany({
30      skip,
31      take: limit,
32      orderBy: { [sort]: order },
33    }),
34    db.post.count(),
35  ]);
36
37  const totalPages = Math.ceil(total / limit);
38
39  res.json({
40    data: posts,
41    pagination: {
42      page,
43      limit,
44      total,
45      totalPages,
46      hasNext: page < totalPages,
47      hasPrev: page > 1,
48    },
49  });
50});

Usage:

typescript
1GET /api/posts?page=2&limit=10&sort=createdAt&order=desc

Filtering and Searching

typescript
1interface PostFilter {
2  author?: string;
3  status?: 'draft' | 'published';
4  tags?: string;
5  search?: string;
6}
7
8app.get('/api/posts', async (req: Request<{}, {}, {}, PostFilter & PaginationQuery>, res) => {
9  const { author, status, tags, search } = req.query;
10
11  const where: any = {};
12
13  if (author) {
14    where.authorId = author;
15  }
16
17  if (status) {
18    where.status = status;
19  }
20
21  if (tags) {
22    where.tags = { hasSome: tags.split(',') };
23  }
24
25  if (search) {
26    where.OR = [
27      { title: { contains: search, mode: 'insensitive' } },
28      { content: { contains: search, mode: 'insensitive' } },
29    ];
30  }
31
32  const posts = await db.post.findMany({
33    where,
34    // ... pagination
35  });
36
37  res.json({ data: posts });
38});

Usage:

typescript
1GET /api/posts?author=user123&status=published&tags=react,typescript&search=hooks

Authentication & Authorization

JWT-Based Auth

typescript
1// middleware/auth.ts
2import jwt from 'jsonwebtoken';
3
4export interface JWTPayload {
5  userId: string;
6  email: string;
7  role: 'user' | 'admin';
8}
9
10export async function authenticate(req: Request, res: Response, next: NextFunction) {
11  try {
12    const token = req.headers.authorization?.replace('Bearer ', '');
13
14    if (!token) {
15      throw new AppError('No token provided', 401, 'UNAUTHORIZED');
16    }
17
18    const payload = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
19
20    // Attach user to request
21    req.user = payload;
22
23    next();
24  } catch (error) {
25    next(new AppError('Invalid token', 401, 'INVALID_TOKEN'));
26  }
27}

Role-Based Authorization

typescript
1// middleware/authorize.ts
2export function authorize(...roles: string[]) {
3  return (req: Request, res: Response, next: NextFunction) => {
4    if (!req.user) {
5      throw new AppError('Not authenticated', 401, 'UNAUTHORIZED');
6    }
7
8    if (!roles.includes(req.user.role)) {
9      throw new AppError('Insufficient permissions', 403, 'FORBIDDEN');
10    }
11
12    next();
13  };
14}

Usage:

typescript
1// Public route
2app.get('/api/posts', getPosts);
3
4// Authenticated route
5app.post('/api/posts', authenticate, createPost);
6
7// Admin-only route
8app.delete('/api/posts/:id', authenticate, authorize('admin'), deletePost);

Versioning

URL Versioning (Recommended)

typescript
1// v1 routes
2app.use('/api/v1/posts', postsV1Router);
3
4// v2 routes with breaking changes
5app.use('/api/v2/posts', postsV2Router);

Header Versioning (Alternative)

typescript
1app.get('/api/posts', (req, res) => {
2  const version = req.headers['api-version'] || '1';
3
4  if (version === '2') {
5    return handleV2(req, res);
6  }
7
8  return handleV1(req, res);
9});

Rate Limiting

typescript
1import rateLimit from 'express-rate-limit';
2
3// General rate limit
4const limiter = rateLimit({
5  windowMs: 15 * 60 * 1000, // 15 minutes
6  max: 100, // 100 requests per window
7  message: {
8    error: {
9      message: 'Too many requests, please try again later',
10      code: 'RATE_LIMIT_EXCEEDED',
11      status: 429,
12    },
13  },
14});
15
16// Stricter limit for auth endpoints
17const authLimiter = rateLimit({
18  windowMs: 15 * 60 * 1000,
19  max: 5, // 5 login attempts
20  skipSuccessfulRequests: true,
21});
22
23app.use('/api', limiter);
24app.use('/api/auth', authLimiter);

Documentation with OpenAPI/Swagger

typescript
1import swaggerJsdoc from 'swagger-jsdoc';
2import swaggerUi from 'swagger-ui-express';
3
4const swaggerSpec = swaggerJsdoc({
5  definition: {
6    openapi: '3.0.0',
7    info: {
8      title: 'Blog API',
9      version: '1.0.0',
10      description: 'RESTful API for blog application',
11    },
12    servers: [
13      { url: 'http://localhost:3000', description: 'Development' },
14      { url: 'https://api.example.com', description: 'Production' },
15    ],
16  },
17  apis: ['./routes/*.ts'],
18});
19
20app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
typescript
1/**
2 * @openapi
3 * /api/posts:
4 *   get:
5 *     summary: Get all posts
6 *     tags: [Posts]
7 *     parameters:
8 *       - in: query
9 *         name: page
10 *         schema:
11 *           type: integer
12 *           default: 1
13 *     responses:
14 *       200:
15 *         description: List of posts
16 *         content:
17 *           application/json:
18 *             schema:
19 *               type: object
20 *               properties:
21 *                 data:
22 *                   type: array
23 *                   items:
24 *                     $ref: '#/components/schemas/Post'
25 */
26app.get('/api/posts', getPosts);

Best Practices Summary

✅ Use resource-based URLs - /api/users not /api/getUsers

✅ Leverage HTTP methods - GET, POST, PUT, PATCH, DELETE correctly

✅ Return proper status codes - 200, 201, 400, 404, 500, etc.

✅ Implement pagination - For list endpoints

✅ Add filtering/sorting - Make data queries flexible

✅ Handle errors consistently - Standard error format

✅ Secure your API - Authentication, authorization, rate limiting

✅ Version your API - Plan for breaking changes

✅ Document everything - OpenAPI/Swagger specs

A well-designed API is a joy to use and maintain. Follow these patterns, and you'll build APIs that scale with your application!

About the Author Backend

Experienced software engineer passionate about creating innovative solutions and sharing knowledge through technical writing.

More from the Blog

Explore more articles and insights on software engineering, technology, and career development.

View all articles

Share this article

Help others discover this article by sharing it

Article Info

Published2025-01-20
Read Time16 mins read
AuthorBackend

Stay Updated

Follow me on Medium to get notified about new articles and insights.

Follow on Medium