You've probably seen it before. A codebase where /api/getUsers, /api/fetchUserById, /api/deleteUserRecord, and /api/updateUserData all exist as separate endpoints with inconsistent patterns. Welcome to the REST API chaos zone.
This guide is about escaping that zone — writing clean, consistent, and maintainable REST APIs that your future self (and your teammates) will actually thank you for.
The Problem with "Just Making It Work"
When we're under deadline pressure, it's tempting to just add another endpoint, name it whatever makes sense in the moment, and ship it. Over time, this creates APIs that are:
- Hard to understand without documentation
- Painful to version and maintain
- Frustrating for frontend developers to consume
- Impossible to predict without reading source code
The good news: a few clear principles can prevent most of this mess.
Principle 1: Resources, Not Actions
The most common mistake is naming endpoints after what you do instead of what you're dealing with.
Bad:
GET /api/getUsers
POST /api/createUser
PUT /api/updateUser
DEL /api/deleteUser
Good:
GET /api/users
POST /api/users
PUT /api/users/:id
DELETE /api/users/:id
The HTTP method already tells you the action. Your URL should describe the resource. Think of your API like a filing cabinet — you don't label drawers "getFiles" and "createFiles", you just label them "Files" and use different verbs to interact with them.
Principle 2: Use HTTP Methods Correctly
This sounds obvious but it's violated constantly:
| Method | Purpose | Idempotent? |
|---|---|---|
| GET | Retrieve data | Yes |
| POST | Create new resource | No |
| PUT | Replace entire resource | Yes |
| PATCH | Partial update | No |
| DELETE | Remove resource | Yes |
A common anti-pattern is using POST for everything because "it's simpler". It's not simpler — it's just hiding complexity from the API contract and pushing confusion onto the caller.
// Express.js example — clean routing
const router = express.Router();
router.get('/users', getUsers); // list all
router.get('/users/:id', getUserById); // get one
router.post('/users', createUser); // create
router.patch('/users/:id', updateUser); // partial update
router.delete('/users/:id', deleteUser); // remove
Principle 3: Nest Resources Thoughtfully
Relationships between resources should be reflected in your URL structure — but only one or two levels deep.
// Good: shows relationship clearly
GET /api/users/:userId/posts
GET /api/users/:userId/posts/:postId
// Too deep: painful to work with
GET /api/users/:userId/teams/:teamId/projects/:projectId/tasks/:taskId/comments
For deeply nested relationships, consider using query parameters instead:
GET /api/comments?userId=123&projectId=456
This keeps URLs readable while still expressing the filter relationship.
Principle 4: Consistent Response Shapes
Your API responses should feel predictable. Pick a shape and stick to it across your entire API.
// Consistent success response
{
"success": true,
"data": {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
},
"meta": {
"timestamp": "2026-03-02T16:00:00Z"
}
}
// Consistent error response
{
"success": false,
"error": {
"code": "USER_NOT_FOUND",
"message": "No user found with the provided ID",
"status": 404
}
}
A middleware approach in Express makes this easy:
// response-helpers.js
export const sendSuccess = (res, data, statusCode = 200) => {
return res.status(statusCode).json({
success: true,
data,
meta: { timestamp: new Date().toISOString() }
});
};
export const sendError = (res, code, message, statusCode = 500) => {
return res.status(statusCode).json({
success: false,
error: { code, message, status: statusCode }
});
};
// In your route handler
const getUserById = async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return sendError(res, 'USER_NOT_FOUND', 'User not found', 404);
}
return sendSuccess(res, user);
};
Principle 5: Use Status Codes Meaningfully
HTTP status codes exist for a reason. Returning 200 OK with an error in the body is a classic mistake that breaks tooling, monitoring, and developer sanity.
200 OK — successful GET, PATCH, PUT
201 Created — successful POST (new resource created)
204 No Content — successful DELETE (nothing to return)
400 Bad Request — invalid input from client
401 Unauthorized — not authenticated
403 Forbidden — authenticated but no permission
404 Not Found — resource doesn't exist
409 Conflict — state conflict (e.g. duplicate email)
422 Unprocessable — valid format, but invalid data
429 Too Many Req — rate limit hit
500 Internal Error — something blew up on your end
Principle 6: Pagination, Filtering, and Sorting
Returning an entire database table in one request is a great way to crash your server. Build pagination in from day one.
// Cursor-based pagination (preferred for large datasets)
GET /api/posts?cursor=abc123&limit=20
// Offset-based pagination (simpler, good for small datasets)
GET /api/posts?page=2&limit=20
// Filtering
GET /api/posts?status=published&authorId=42
// Sorting
GET /api/posts?sort=createdAt&order=desc
Return pagination metadata so clients know where they are:
{
"success": true,
"data": [...],
"pagination": {
"page": 2,
"limit": 20,
"total": 340,
"hasNext": true,
"hasPrev": true
}
}
Principle 7: Version Your API from Day One
This one hurts people who skip it. Once your API is public, breaking changes are your enemy.
// URL versioning — most common, very explicit
https://api.example.com/v1/users
https://api.example.com/v2/users
// Header versioning — cleaner URLs, but less discoverable
Accept: application/vnd.myapi.v2+json
URL versioning wins for most teams because it's visible, cacheable, and obvious. Start with /v1/ from the very beginning — retrofitting versioning into an existing API is painful.
Putting It All Together
Here's what a clean API router looks like for a blogging platform:
// routes/index.js
import { Router } from 'express';
import userRoutes from './users';
import postRoutes from './posts';
import commentRoutes from './comments';
const v1Router = Router();
v1Router.use('/users', userRoutes);
v1Router.use('/posts', postRoutes);
v1Router.use('/posts/:postId/comments', commentRoutes);
export default v1Router;
// app.js
app.use('/api/v1', v1Router);
// routes/posts.js
import { Router } from 'express';
import { authenticate } from '../middleware/auth';
import {
listPosts,
getPost,
createPost,
updatePost,
deletePost
} from '../controllers/posts';
const router = Router();
router.get('/', listPosts); // GET /api/v1/posts
router.get('/:id', getPost); // GET /api/v1/posts/:id
router.post('/', authenticate, createPost); // POST /api/v1/posts
router.patch('/:id', authenticate, updatePost); // PATCH /api/v1/posts/:id
router.delete('/:id', authenticate, deletePost); // DELETE /api/v1/posts/:id
export default router;
Clean, readable, and consistent. Any new developer on the team can understand what each route does without reading the controller code.
Quick Checklist Before You Ship
Before you push your API to production, run through this:
- [ ] URLs use nouns, not verbs
- [ ] HTTP methods used correctly (GET, POST, PUT/PATCH, DELETE)
- [ ] Consistent response shape across all endpoints
- [ ] Meaningful HTTP status codes returned
- [ ] Pagination on all list endpoints
- [ ] API is versioned
- [ ] Error responses include actionable messages
- [ ] Authentication applied where needed
Final Thoughts
Clean API design is a form of communication. Your API is a contract with every developer who calls it — frontend devs on your team, third-party integrators, or your future self three years from now.
The extra ten minutes you spend naming things consistently and returning proper status codes will save hours of debugging and confusion down the line.
Start with these principles on your next project, and you'll never look back at the spaghetti route days again.
Have a pattern you swear by that I didn't cover? Drop it in the comments — always happy to learn a better approach.
Top comments (0)