Shortcuts
BlogDecember 1, 2024

Mohamed Elbarry
RESTful API Design Principles
The APIs I enjoy integrating are the ones where I can guess the next endpoint. Resources as nouns, HTTP methods for actions, and a consistent response shape. I’ve maintained a few REST APIs that drifted into “/getUsers” and action-based routes; refactoring them was painful. Here’s how I design them so they stay intuitive. I've used this approach on backends for Lumin Search and similar products. I use nouns for resources and HTTP methods for the action. No verbs in the path.
Http
GET    /users
GET    /users/123
POST   /users
PUT    /users/123
DELETE /users/123
Avoid action-based URLs like /getUsers or /createUser—they duplicate what the method already says and make the surface area noisy. When a resource logically belongs to another, I nest it. For things that stand alone (e.g. order items that are also referenced elsewhere), I sometimes expose them under the parent for convenience and keep a top-level route if needed.
Http
GET    /users/123/orders
GET    /users/123/orders/456
POST   /users/123/orders

GET    /orders/456/items
POST   /orders/456/items
I stick to the usual semantics: GET for read, POST for create, PUT for full replace, PATCH for partial update, DELETE for remove. Returning the right status code helps clients and proxies (caching, retries). The HTTP/1.1 semantics RFC is where this is actually defined if you want to go to the source. I use 422 for validation errors so they’re distinct from generic 400.
Tsx
// Success
200 OK          // GET, PUT, PATCH
201 Created     // POST
204 No Content  // DELETE

// Client errors
400 Bad Request       // Malformed request
401 Unauthorized      // Not authenticated
403 Forbidden         // Not allowed
404 Not Found         // Resource missing
409 Conflict          // e.g. duplicate, version conflict
422 Unprocessable Entity // Validation failed

// Server errors
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
Example handlers:
Tsx
app.get('/users', async (req, res) => {
  const users = await userService.getAllUsers();
  res.json(users);
});

app.post('/users', async (req, res) => {
  try {
    const user = await userService.createUser(req.body);
    res.status(201).json(user);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

app.put('/users/:id', async (req, res) => {
  try {
    const user = await userService.updateUser(req.params.id, req.body);
    res.json(user);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});
Clients parse one shape for success and one for errors. I wrap success in a small envelope (e.g. data + optional meta) and errors in a fixed structure with a code and message so the frontend can branch without string matching.
Tsx
interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    message: string;
    code: string;
    details?: any;
  };
  meta?: {
    pagination?: {
      page: number;
      limit: number;
      total: number;
      totalPages: number;
    };
    timestamp: string;
  };
}

function sendSuccess<T>(res: Response, data: T, statusCode = 200) {
  const response: ApiResponse<T> = {
    success: true,
    data,
    meta: {
      timestamp: new Date().toISOString()
    }
  };
  res.status(statusCode).json(response);
}
Pagination stays in meta.pagination so list responses are consistent:
Tsx
app.get('/users', async (req, res) => {
  try {
    const { page = 1, limit = 10, sort = 'id', order = 'asc' } = req.query;

    const paginationParams = {
      page: parseInt(page as string),
      limit: parseInt(limit as string),
      sort: sort as string,
      order: order as 'asc' | 'desc'
    };

    const result = await userService.getUsersPaginated(paginationParams);

    const response = {
      data: result.users,
      pagination: {
        page: paginationParams.page,
        limit: paginationParams.limit,
        total: result.total,
        totalPages: Math.ceil(result.total / paginationParams.limit),
        hasNext: paginationParams.page < Math.ceil(result.total / paginationParams.limit),
        hasPrev: paginationParams.page > 1
      }
    };

    sendSuccess(res, response);
  } catch (error) {
    sendError(res, 'Failed to fetch users', 'FETCH_ERROR', 500);
  }
});
A small set of error codes makes it easier to handle errors on the client and in logs. I map domain errors to these and set the HTTP status accordingly.
Tsx
enum ErrorCode {
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  NOT_FOUND = 'NOT_FOUND',
  UNAUTHORIZED = 'UNAUTHORIZED',
  FORBIDDEN = 'FORBIDDEN',
  CONFLICT = 'CONFLICT',
  INTERNAL_ERROR = 'INTERNAL_ERROR'
}

class ApiError extends Error {
  constructor(
    public message: string,
    public code: ErrorCode,
    public statusCode: number = 400,
    public details?: any
  ) {
    super(message);
    this.name = 'ApiError';
  }
}
Protected routes check the token and attach the user to the request. I return 401 when the token is missing or invalid so the client can refresh or redirect to login.
Tsx
function authenticateToken(req: Request, res: Response, next: NextFunction) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  jwt.verify(token, process.env.JWT_SECRET!, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid token' });
    }
    req.user = user as any;
    next();
  });
}
Resources as nouns, HTTP methods for actions, and stable status codes. One response envelope for success and one for errors, with a small set of error codes. For versioning, URL path (e.g. /v1/users) is what most big APIs use—Google, Twitter, Stripe—because it’s obvious in logs and easy to test. Header-based versioning (e.g. Accept: application/vnd.api.v2+json) is more “RESTful” in theory but harder to debug and less common in practice. I version in the path when I need breaking changes. If you’re starting a new API, pick one response shape and one error format and use them everywhere. For more, the HTTP status code RFC and REST API Tutorial are solid references. Good luck with it. I'm currently looking for new challenges in the AI and Full Stack space. If you're building something interesting, let's chat.
Share this post: