A comprehensive guide to building professional REST APIs that go beyond just working code to focus on consistency, scalability, and developer experience.
Your API Works. But Does It Slap? So you've been vibing. You prompted your way into a backend, Cursor autocompleted half the routes, and somehow the thing actually works. Respect. Genuinely. Most developers don't actually design REST APIs, they just return JSON and call it done. But professional API design is about more than making things work. It's about consistency, scalability, predictability, and developer experience. This guide walks through the 10 most common REST API mistakes and how to fix each one with real code examples.
- Use Resource-Based URLs (No Verbs) Be honest. You have endpoints that look like this:
GET /getUsers POST /createUser PUT /updateUser/4 DELETE /deleteUser/5
It feels natural! It reads like English! It is also wrong. REST is about resources, not actions. Your HTTP method already defines the action, so don't repeat it in the URL.
❌ Wrong GET /getUsers POST /createUser DELETE /deleteUser/5
✅ Correct GET /users POST /users DELETE /users/5
The HTTP method tells you what to do. The URL tells you what resource you're acting on.
| Method | Meaning |
|---|---|
| GET | Retrieve |
| POST | Create |
| PUT / PATCH | Update |
| DELETE | Remove |
Rule: URLs are nouns. HTTP methods are verbs.
- Use The Right HTTP Status Codes Correctly This one hurts to see. An API that returns 200 OK even when things go wrong: Returning 200 OK for everything, including errors, is a common anti-pattern. Status codes are part of your API contract.
❌ Wrong HTTP 200 OK { "status": "error", "message": "User not found" }
✅ Correct // Resource found HTTP 200 OK
// Resource created HTTP 201 created
// Bad Request HTTP 400 Bad request
// Validation error HTTP 422 Unprocessable Entity
// Not authenticated HTTP 401 Unauthorized
// Authenticated but not allowed HTTP 403 Forbidden
// Resource doesn't exist HTTP 404 Not Found
// Server error HTTP 500 Internal Server Error
Example in Node.js (Express)
app.get('/users/:id', async (req, res) => { const user = await User.findById(req.params.id); if (!user) { return res.status(404).json({ error: { code: 404, message: 'User not found' } }); } return res.status(200).json({ data: user }); });
- Keep JSON Naming Consistent Mixing camelCase, snake_case, and lowercase across endpoints forces front-end developers to guess the format and that slows everyone down.
❌ Wrong // Endpoint A { "userName": "alice", "user_email": "[email protected]" }
// Endpoint B { "username": "bob", "userEmail": "[email protected]" }
✅ Correct — pick one and stick to it
snake_case (common in Laravel/Python APIs): { "user_name": "alice", "user_email": "[email protected]", "created_at": "2024-01-15T10:00:00Z" }
camelCase (common in Node.js APIs): { "userName": "alice", "userEmail": "[email protected]", "createdAt": "2024-01-15T10:00:00Z" }
Rule: It doesn't matter which you choose. What matters is that you use it everywhere.
- Version Your API from Day One Skipping versioning feels harmless until you need to change a response structure and suddenly every client breaks.
❌ Wrong /users ← you change this and everything breaks
✅ Correct /api/v1/users ← stable /api/v2/users ← new version when needed
Future you is begging present you to do this one thing.
Example: Express Router with versioning
const v1Router = require('./routes/v1'); const v2Router = require('./routes/v2'); app.use('/api/v1', v1Router); app.use('/api/v2', v2Router);
Old clients keep using v1. New clients adopt v2. No broken production systems.
- Implement Pagination Returning 10,000 records in a single response will slow your server, waste bandwidth, and hurt user experience.
❌ Wrong GET /users ← returns all 8,700 users ← your server starts crying ← your users leave
✅ Correct GET /users?page=1&limit=10
Expected response structure { "data": [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ], "meta": { "current_page": 1, "per_page": 10, "total": 1000, "last_page": 100 } }
Example in Node.js
app.get('/users', async (req, res) => { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const offset = (page - 1) * limit; const { rows: users, count: total } = await User.findAndCountAll({ limit, offset }); res.status(200).json({ data: users, meta: { current_page: page, per_page: limit, total, last_page: Math.ceil(total / limit) } }); });
- Separate Authentication from Authorization A lot of vibe code treats these like they're the same. They're not. These are two different things and mixing them creates security gaps.
| Concept | Question it answers |
|---|---|
| Authentication | Who are you? (identity) |
| Authorization | What are you allowed to do? (permissions) |
✅ Correct approach in Node.js (JWT)
// Middleware: Authentication const authenticate = (req, res, next) => { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Unauthorized' }); try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Invalid token' }); } };
// Middleware: Authorization const authorize = (role) => (req, res, next) => { if (req.user.role !== role) { return res.status(403).json({ error: 'Forbidden' }); } next(); };
// Usage app.delete('/users/:id', authenticate, authorize('admin'), deleteUser);
Never rely on front-end validation alone. Hidden buttons don't protect endpoints. Security must be enforced on the server.
- Return Structured Error Responses Vague error messages like "Something went wrong" are useless for front-end developers, for logging, and for debugging.
❌ Wrong HTTP 500 { "message": "Something went wrong" }
This tells nobody anything. What went wrong? Where? Why? Your front-end team can't handle it properly. You can't debug it. It's just vibes in JSON form.
✅ Correct HTTP 404 { "error": { "code": 404, "message": "User not found", "field": null } }
HTTP 422 { "error": { "code": 422, "message": "Validation failed", "fields": { "email": "The email field is required.", "password": "Password must be at least 8 characters." } } }
Reusable error helper in Node.js
const errorResponse = (res, statusCode, message, fields = null) => { return res.status(statusCode).json({ error: { code: statusCode, message, ...(fields && { fields }) } }); };
// Usage errorResponse(res, 404, 'User not found'); errorResponse(res, 422, 'Validation failed', { email: 'Email is required' });
- Use Query Parameters for Filtering and Sorting Creating a new endpoint for every filter combination doesn't scale.
❌ Wrong GET /getActiveUsersSortedByName GET /getInactiveUsersSortedByDate GET /getUsersByRole
✅ Correct GET /users?status=active&sort=name&order=asc GET /users?status=inactive&sort=created_at&order=desc GET /users?role=admin
Example in Node.js
app.get('/users', async (req, res) => { const { status, sort = 'created_at', order = 'asc', role } = req.query; const where = {}; if (status) where.status = status; if (role) where.role = role; const users = await User.findAll({ where, order: [[sort, order.toUpperCase()]] }); res.status(200).json({ data: users }); });
- Enforce Security from Day One Security is not something you add after deployment. It's built in from the start. Common mistakes to avoid
| Mistake | Risk |
|---|---|
| No rate limiting | Brute force & abuse |
| No input validation | SQL injection, XSS |
| No HTTPS | Data interception |
| Exposing sensitive fields | Token/password leakage |
Rate limiting in Express
const rateLimit = require('express-rate-limit'); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, message: { error: { code: 429, message: 'Too many requests' } } }); app.use('/api/', limiter);
Input validation (using Joi)
const Joi = require('joi'); const userSchema = Joi.object({ name: Joi.string().min(2).max(50).required(), email: Joi.string().email().required(), password: Joi.string().min(8).required() });
app.post('/users', (req, res) => { const { error } = userSchema.validate(req.body); if (error) { return res.status(422).json({ error: { code: 422, message: error.details[0].message } }); } // proceed... });
Hide sensitive fields (Laravel Resource)
class UserResource extends JsonResource { public function toArray($request) { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, 'created_at' => $this->created_at, // password, token, internal_id are intentionally omitted ]; } }
- Design Around Client Needs, Not Your Database Your database schema is an internal implementation detail. Your API is a public contract.
❌ Wrong mindset "This is how my database table looks, so this is how my response should look."
✅ Right mindset "This is what the client needs. I'll shape the response to serve that."
Example: Combining fields and transforming data
Database has: first_name, last_name, dob (date of birth) Client needs: full_name, age
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: { code: 404, message: 'User not found' } });
const age = new Date().getFullYear() - new Date(user.dob).getFullYear();
res.status(200).json({
data: {
id: user.id,
full_name: ${user.first_name} ${user.last_name},
email: user.email,
age // internal columns like dob, password_hash are hidden
}
});
});
Your database can change. Your implementation can evolve. But your API contract must remain stable designed for the client, not for the table.
Summary: What Professional REST APIs Look Like
| Principle | What it means |
|---|---|
| ✅ Resource-based URLs | Nouns in URLs, verbs via HTTP methods |
| ✅ Correct HTTP status codes | 201 for created, 404 for missing, 422 for invalid, etc. |
| ✅ Consistent naming | snake_case or camelCase — never both |
| ✅ API versioning | /api/v1/ from day one |
| ✅ Pagination | Never return unbounded record sets |
| ✅ Auth + Authz separated | Identity vs. permissions are distinct |
| ✅ Structured errors | Predictable, machine-readable error objects |
| ✅ Query param filtering | One endpoint, flexible behavior |
| ✅ Security by default | HTTPS, rate limiting, validation, no sensitive leaks |
| ✅ Client-first design | API shape driven by what clients need |
The difference between it works and it's well-designed is consistency, predictability, and care for the developer experience. Design it right from the beginning, your future self (and your teammates) will thank you.
You don't have to do all of this perfectly on day one. But the earlier you build these habits in, the less you'll be fighting your own code later. The AI can write the routes. You have to make them good.
Ship clean. 🚀


Comments
Please log in or register to join the discussion