Stop Writing Spaghetti APIs: A Practical Guide to Clean REST API Design
We've all been there. You join a new project, open up the API docs (if they even exist), and suddenly you're staring into the void. Endpoints like /getUser, /updateUserData2, /fetchOrdersByUserIdAndStatus_v3... it's a horror show.
The truth is, bad API design is one of the most expensive technical debts a team can accumulate. It slows down frontend devs, confuses consumers, and makes maintenance a nightmare. But here's the good news: clean REST API design isn't rocket science. It's a set of principles you can start applying today.
Let's walk through the most common mistakes and how to fix them — with real examples.
1. Name Your Resources, Not Your Actions
The single biggest mistake I see in REST APIs is using verbs in endpoint paths.
# ❌ Bad — RPC-style thinking
GET /getUsers
POST /createUser
POST /deleteUser
POST /updateUserEmail
# ✅ Good — Resource-oriented
GET /users
POST /users
DELETE /users/{id}
PATCH /users/{id}
REST is built around resources (nouns), not actions (verbs). The HTTP method is the action. Trust it.
Think of your API like a filing cabinet. You don't say "getFolderFromCabinet" — you just open the users drawer and either read, add, update, or remove a file.
2. Use HTTP Methods Correctly
This sounds obvious, but you'd be surprised. Here's the quick reference:
| Method | Purpose | Idempotent? |
|---|---|---|
| GET | Retrieve resource(s) | ✅ Yes |
| POST | Create a new resource | ❌ No |
| PUT | Replace a resource entirely | ✅ Yes |
| PATCH | Partially update a resource | Usually ✅ |
| DELETE | Remove a resource | ✅ Yes |
Idempotent means calling the same request multiple times produces the same result. This matters for retry logic and caching.
A classic mistake: using POST for everything because it "feels safer". It breaks caching, confuses clients, and makes your API unpredictable.
3. Embrace Consistent Status Codes
HTTP status codes are a language. Speak it fluently.
// Express.js example — proper status codes
// ✅ 201 Created when a resource is successfully created
app.post('/users', async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ data: user });
});
// ✅ 404 Not Found when resource doesn't exist
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: { message: 'User not found', code: 'USER_NOT_FOUND' }
});
}
res.status(200).json({ data: user });
});
// ✅ 422 Unprocessable Entity for validation errors
app.post('/users', async (req, res) => {
const { error } = validateUser(req.body);
if (error) {
return res.status(422).json({
error: { message: 'Validation failed', details: error.details }
});
}
// ...
});
The most commonly misused codes:
- 200 for everything (even errors!) — stop this immediately
- 500 when validation fails — that's a 400-level error, not a server error
- 404 when authorization fails — use 403 Forbidden
4. Structure Your Responses Consistently
Nothing frustrates API consumers more than inconsistency. Sometimes you return { user: {...} }, sometimes { data: {...} }, sometimes just the raw object.
Pick a structure and stick with it:
// A clean, consistent response envelope
// Success
{
"data": {
"id": "usr_123",
"name": "Teguh",
"email": "teguh@example.com",
"createdAt": "2026-02-27T02:00:00Z"
},
"meta": {
"requestId": "req_abc456"
}
}
// Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is already in use",
"details": [
{ "field": "email", "message": "Must be unique" }
]
},
"meta": {
"requestId": "req_abc456"
}
}
// Paginated list
{
"data": [...],
"pagination": {
"page": 1,
"perPage": 20,
"total": 200,
"totalPages": 10
}
}
Notice the requestId in meta? That's gold for debugging. Generate a unique ID per request and log it server-side. When users report issues, they can give you the ID and you can trace exactly what happened.
5. Version Your API from Day One
This is the one mistake that's almost impossible to fix retroactively without breaking things.
# Version in the URL path (most common, easiest)
https://api.yourdomain.com/v1/users
https://api.yourdomain.com/v2/users
# Version in headers (more "pure" REST, harder for consumers)
GET /users
Accept: application/vnd.yourapi.v2+json
URL versioning is less elegant but far more practical. It's obvious, cacheable, and easy to test in a browser.
Start with v1 even if you think your API is perfect. It will change. It always does.
6. Design Nested Resources Thoughtfully
Relationships between resources are tricky. Here's the mental model:
# ✅ Nest when the child resource only makes sense in context of the parent
GET /users/{userId}/orders # Orders belonging to a user
GET /posts/{postId}/comments # Comments on a post
POST /posts/{postId}/comments # Add comment to a post
# ❌ Avoid deep nesting — it gets messy
GET /users/{userId}/orders/{orderId}/items/{itemId}/reviews
# ✅ Better: flatten deep hierarchies
GET /order-items/{itemId}/reviews
A good rule of thumb: maximum 2 levels of nesting. If you need to go deeper, flatten it.
7. Handle Filtering, Sorting, and Pagination Gracefully
# Filtering
GET /orders?status=pending&userId=123
# Sorting (use a consistent convention)
GET /products?sort=price&order=asc
GET /products?sort=-price # Minus prefix = descending (popular pattern)
# Pagination — cursor-based is better for large datasets
GET /posts?page=1&perPage=20 # Offset-based (simple)
GET /posts?cursor=eyJpZCI6MTIzfQ== # Cursor-based (scalable)
# Field selection (sparse fieldsets)
GET /users?fields=id,name,email # Return only what's needed
Never return unbounded lists. Always paginate. An endpoint that returns 50,000 records because no one thought to add pagination has probably taken down a production server before.
8. Document as You Build
The best API documentation is one that writes itself. Use OpenAPI (Swagger) specs:
# openapi.yaml — a taste of what self-documenting looks like
openapi: 3.0.0
info:
title: My Clean API
version: 1.0.0
paths:
/users/{id}:
get:
summary: Get a user by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'404':
description: User not found
Tools like Swagger UI, Redoc, and Scalar can turn this YAML into beautiful interactive docs automatically.
Quick Wins Checklist
Before you ship your next API, run through this:
- [ ] Resource names are plural nouns (
/users, not/useror/getUsers) - [ ] HTTP methods are used correctly (no
POSTfor everything) - [ ] Status codes actually reflect what happened
- [ ] Responses follow a consistent envelope structure
- [ ] API is versioned (
/v1/...) - [ ] All list endpoints are paginated
- [ ] Error responses include a machine-readable
codefield - [ ] A
requestIdis included in every response - [ ] OpenAPI spec exists (even if minimal)
Final Thoughts
Clean API design is an act of empathy. Every endpoint you craft is something another developer — maybe future-you — will have to consume and maintain. The extra 20 minutes you spend naming things well and using the right status codes will save hours of confusion down the road.
The best APIs feel intuitive. You can almost guess the endpoint before you look it up. That's the goal.
Start small. Pick one project and apply these principles. Refactor one bad endpoint. Add versioning to one API. The habits compound.
Happy coding! 🚀
Found this helpful? Share it with a teammate who's building APIs. And if you've got your own API design tips, drop them in the comments!
Top comments (0)