REST API Design Best Practices (Naming, Status Codes, Versioning)
Master RESTful API design with naming conventions, HTTP status codes, versioning strategies, pagination patterns, and consistent response shapes.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
I've reviewed a lot of APIs over the years — good ones, confusing ones, and a few that made me genuinely question my career choices. The difference between an API that developers love and one they complain about in Slack almost always comes down to the basics: naming, status codes, versioning, and consistent structure.
This guide covers the conventions that experienced backend engineers follow when designing RESTful APIs. If you're building something new, these principles will save you (and your consumers) a lot of headaches. And if you're maintaining something old, you'll at least understand why things feel off.
Naming Conventions: Nouns, Not Verbs
The single most common mistake I see in API design is using verbs in endpoint paths. Something like /getUsers or /deletePost — it's intuitive at first glance, but it misses the entire point of REST.
In REST, the resource lives in the URL. The action comes from the HTTP method. So instead of:
GET /getUsers
POST /createUser
DELETE /deleteUser/123
You want:
GET /users
POST /users
DELETE /users/123
The pattern is clean: /users refers to the collection. /users/123 refers to a specific user. You don't need to repeat "user" in the verb because the path already tells you what you're working with.
Plurals vs Singulars
Use plural nouns for collections. Always. Even when you're fetching a single item, the path /users/123 reads as "the user with ID 123 from the users collection" — which is accurate and consistent.
Don't mix /user/123 and /users. Pick one pattern and stick to it across your entire API.
Nested Resources
When one resource belongs to another, nesting makes sense — up to a point:
GET /users/123/posts # posts by user 123
GET /users/123/posts/456 # specific post by user 123
Two levels deep is fine. Three or more starts to get unwieldy. If you find yourself writing /organizations/1/teams/2/members/3/roles, step back and consider whether a flat structure with query parameters would serve better.
Query Parameters for Filtering
Filtering, sorting, and searching belong in query parameters, not path segments:
GET /posts?status=published&category=tech
GET /users?sort=created_at&order=desc
GET /products?search=laptop&min_price=500
This keeps your path clean and gives clients the flexibility to combine filters without needing a new endpoint for every combination.
If you want a deeper dive into how REST compares to alternative API styles, the REST vs GraphQL guide covers the tradeoffs well.
HTTP Status Codes: Use Them Correctly
Status codes aren't decorative. They tell the client — and any middleware between client and server — exactly what happened. Returning 200 OK with {"success": false, "error": "not found"} in the body is a common anti-pattern that breaks caching, monitoring, and error handling.
Here's a practical reference table:
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH requests |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE (no body needed) |
| 400 | Bad Request | Client sent invalid data or malformed JSON |
| 401 | Unauthorized | Missing or invalid authentication credentials |
| 403 | Forbidden | Authenticated but not authorized for this action |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | State conflict (e.g., duplicate email on registration) |
| 422 | Unprocessable Entity | Validation errors on submitted data |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Something broke on the server |
| 503 | Service Unavailable | Server is temporarily down or overloaded |
A few things worth noting: 401 vs 403 trips people up constantly. If a user isn't logged in, return 401. If they're logged in but lack permission, return 403. The distinction matters for clients that need to decide whether to redirect to a login page.
422 vs 400 is another common confusion. Use 400 when the request itself is malformed — wrong content type, unparseable JSON. Use 422 when the JSON is valid but the data doesn't pass your business logic validation (required field missing, email format wrong, etc.).
Error Response Shape
Your error responses need structure. Something like this works well:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Must be a valid email address"
}
]
}
}
The code field is a machine-readable string your clients can handle programmatically. The message is human-readable. The details array is optional but incredibly useful for form validation scenarios.
For more on securing your API endpoints, the API authentication guide walks through auth patterns in detail.
Versioning Strategies
APIs change. New fields get added, old ones get deprecated, and sometimes the entire data model shifts. If you don't version your API from day one, every breaking change becomes an emergency.
URL Versioning
The most common approach, and the one I'd recommend for most projects:
https://api.example.com/v1/users
https://api.example.com/v2/users
Pros: Easy to test in a browser. Obvious in logs and documentation. Easy to route to different handlers. Clients can migrate incrementally.
Cons: Technically, URLs should identify resources, not versions. Some REST purists will argue about this.
Header Versioning
GET /users
Accept: application/vnd.myapi+json; version=2
Pros: Keeps URLs clean. Version is part of the content negotiation.
Cons: Harder to test without tools. Can't bookmark or share versioned URLs. Documentation is less obvious.
Query Parameter Versioning
GET /users?version=2
I'd avoid this one. Query parameters are for filtering and pagination. Using them for versioning mixes concerns and makes your URLs harder to cache.
What Counts as a Breaking Change?
Worth knowing before you decide on a versioning strategy:
- Removing a field from a response → breaking
- Changing a field's data type → breaking
- Renaming a field → breaking
- Adding a new required request field → breaking
- Adding a new optional response field → non-breaking
- Adding a new optional request parameter → non-breaking
Add additive changes freely. Version bump only when you must break something.
Pagination: Don't Return Everything
An endpoint that returns all 50,000 records without pagination isn't an API — it's a memory leak waiting to happen. Pick a pagination strategy and apply it consistently.
Offset/Limit Pagination
GET /posts?limit=20&offset=40
Simple, familiar, and supported by virtually every database. The downside: if records are inserted or deleted between page requests, users can see duplicates or skip records. Fine for most cases.
Cursor-Based Pagination
GET /posts?limit=20&after=eyJpZCI6NDJ9
The cursor is typically a Base64-encoded representation of the last item in the previous page. More stable than offset pagination for real-time data feeds. Twitter, Facebook, and Stripe all use cursor-based pagination for their timelines and transaction lists.
Response Shape for Paginated Data
{
"data": [...],
"pagination": {
"total": 1250,
"limit": 20,
"offset": 40,
"next": "/posts?limit=20&offset=60",
"prev": "/posts?limit=20&offset=20"
}
}
Including next and prev links (sometimes called HATEOAS links) lets clients navigate without constructing URLs themselves.
Response Shape: Be Consistent
Nothing frustrates API consumers more than inconsistency. Some endpoints return { "user": {...} }, others return the object directly, and some return { "data": {...} }. Pick one envelope format and use it everywhere.
Here's a structure I've found works well:
{
"data": {
"id": "123",
"type": "user",
"attributes": {
"name": "Alice",
"email": "alice@example.com"
}
},
"meta": {
"requestId": "req_abc123",
"timestamp": "2026-05-31T10:00:00Z"
}
}
For collections:
{
"data": [...],
"meta": {
"total": 150,
"page": 2
},
"links": {
"next": "/users?page=3",
"prev": "/users?page=1"
}
}
This is loosely inspired by the JSON:API specification, which is worth reading even if you don't follow it strictly. It solves a lot of design questions you'll eventually hit.
The API tutorial for beginners covers these concepts from a more introductory angle if you're newer to the space.
Real Node.js/Express Examples
Let me tie this together with concrete code. Here's an Express router that follows all these conventions:
const express = require('express');
const router = express.Router();
// GET /v1/users - list with pagination
router.get('/users', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = parseInt(req.query.offset) || 0;
try {
const [users, total] = await Promise.all([
User.find({}).skip(offset).limit(limit),
User.countDocuments()
]);
res.status(200).json({
data: users,
meta: { total, limit, offset },
links: {
next: offset + limit < total ? `/v1/users?limit=${limit}&offset=${offset + limit}` : null,
prev: offset > 0 ? `/v1/users?limit=${limit}&offset=${Math.max(0, offset - limit)}` : null
}
});
} catch (err) {
res.status(500).json({
error: { code: 'SERVER_ERROR', message: 'An unexpected error occurred' }
});
}
});
// POST /v1/users - create
router.post('/users', async (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
details: [
!name && { field: 'name', message: 'Name is required' },
!email && { field: 'email', message: 'Email is required' }
].filter(Boolean)
}
});
}
try {
const user = await User.create({ name, email });
res.status(201).json({ data: user });
} catch (err) {
if (err.code === 11000) {
return res.status(409).json({
error: { code: 'CONFLICT', message: 'Email already exists' }
});
}
res.status(500).json({
error: { code: 'SERVER_ERROR', message: 'An unexpected error occurred' }
});
}
});
// GET /v1/users/:id
router.get('/users/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: 'User not found' }
});
}
res.status(200).json({ data: user });
} catch (err) {
res.status(500).json({
error: { code: 'SERVER_ERROR', message: 'An unexpected error occurred' }
});
}
});
// DELETE /v1/users/:id
router.delete('/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: 'User not found' }
});
}
res.status(204).send();
} catch (err) {
res.status(500).json({
error: { code: 'SERVER_ERROR', message: 'An unexpected error occurred' }
});
}
});
module.exports = router;
Notice the 204 on DELETE — no body, no JSON. The status code is the response.
For building a full API from scratch with this stack, the Node.js Express MongoDB tutorial walks through the complete setup.
If you're comparing performance characteristics between API styles, GraphQL vs REST performance has benchmark data worth reviewing.
Versioning Comparison Table
| Strategy | URL Example | Testability | Cacheability | Recommended |
|---|---|---|---|---|
| URL path | /v1/users | Excellent | Excellent | Yes |
| Request header | X-API-Version: 2 | Moderate | Poor | Conditional |
| Accept header | Accept: ...v=2 | Poor | Poor | No |
| Query param | /users?v=2 | Good | Moderate | No |
A Few More Things Worth Knowing
Use HTTPS everywhere. This isn't optional. HTTP traffic can be intercepted, and credentials in headers are especially vulnerable.
Rate limit your endpoints. Return 429 with a Retry-After header when limits are hit. Document your rate limits in your API docs.
CORS headers matter. If your API will be called from browsers, configure CORS correctly. Don't wildcard * in production unless you really mean it.
Idempotency keys for financial operations. If a client sends the same payment request twice (network retry, duplicate click), you want to return the same response rather than charging twice. Accept an Idempotency-Key header and cache responses keyed to it.
The FastAPI tutorial covers similar design principles from a Python perspective if you prefer that stack.
Conclusion
Good REST API design isn't complicated — it just requires consistency and discipline. Use nouns in your paths, let HTTP methods carry the action, return accurate status codes, version from day one, paginate every collection endpoint, and keep your response shape consistent across the board.
The developers consuming your API will notice. They'll be able to predict how your endpoints behave without reading the docs for every single one. That predictability is what separates a well-designed API from one that requires constant support.
Start with these conventions on your next project, and you'll have a foundation that scales as your API grows. If you're documenting your API, the OpenAPI/Swagger guide is the natural next step.
FAQ
Should REST API endpoints use nouns or verbs?
Always use nouns, not verbs. The HTTP method (GET, POST, PUT, DELETE) already expresses the action. So /users is correct, while /getUsers or /createUser breaks the convention. Nouns describe the resource you're working with, and the verb comes from the HTTP method itself.
What HTTP status code should I return when a resource is not found?
Return 404 Not Found. If the resource exists but the user doesn't have permission to view it, return 403 Forbidden — or 404 if you don't want to reveal the resource's existence. Never return 200 with an error message in the body; that defeats the purpose of status codes.
Should I version my API in the URL or in the request header?
Both approaches work, but URL versioning (/v1/users) is more common and far easier to test with a browser or curl. Header versioning (Accept: application/vnd.api+json;version=2) is cleaner in theory but harder to discover. For most teams, URL versioning wins on practicality.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Document Your API With OpenAPI 3.0 (Swagger Tutorial)
Write clear, interactive API docs using OpenAPI 3.0 and Swagger UI. Includes full YAML examples, Express setup, spec-first vs code-first comparison, and auto-generation tips.
GraphQL vs REST: Which Should You Learn First? (2026 Guide)
Deciding between GraphQL and REST as a junior developer? Compare learning curves, hiring demand, and real use cases to pick the right starting point.
GraphQL vs REST: Real-World Performance Test and Benchmarks
Real benchmark results comparing GraphQL and REST APIs on response time, payload size, and network requests — with honest analysis of over-fetching, under-fetching, and when each wins.
How to Deploy a LangChain App as a FastAPI REST Endpoint
Serve a LangChain app as a production FastAPI REST endpoint with streaming, async chains, error handling, and Docker deployment — full Python code included.