Node.js: REST API Design
Last updated: 2026-08-26
Alice's team is developing both the front end and back end for a task management system. The front-end developers are complaining that they don't know which APIs are available, which HTTP methods to use, or what formats the responses will be in. The back-end developers are just as frustrated—for the same "update task" operation, some use POST, some use PUT, and others use PATCH, resulting in a wide variety of response formats. Collaboration has descended into chaos.
Alice decided to adopt the REST specification. After the team standardized resource naming, method mapping, status codes, and response formats, the API became clear and predictable; the front-end team no longer had to repeatedly check the API documentation, and collaboration efficiency doubled.
1. What You'll Learn
- The Four Core Principles of REST Architecture
- Correct Mapping of CRUD Operations to HTTP Methods
- Dos and Don'ts of RESTful URL Design
- Strategies for Selecting HTTP Status Codes
- JSON Specifications for Requests and Responses
- Three Strategies for API Versioning
- The REST Maturity Model and HATEOAS
2. REST Architecture Principles
(1) What is REST?
REST (Representational State Transfer) is a software architectural style proposed by Roy Fielding in 2000. It defines a set of constraints for designing interfaces for web applications. REST is not a protocol or a standard, but rather a design philosophy.
(2) Four Core Principles
| Principle | Meaning | Example |
|---|---|---|
| Resource | Everything is a resource, identified by a URL | /tasks, /users/42 |
| Representation Layer | The format in which a resource is represented, such as JSON | {"id": 1, "title": "Learn REST"} |
| Stateless | Each request contains all necessary information | Requests carry a token and do not rely on sessions |
| Uniform Interface | Operate on resources using standard HTTP methods | GET to read, POST to create, DELETE to delete |
▶ Example: Stateless vs. Stateful
// Stateful: Depends on server session (not RESTful)
app.post('/login', (req, res) => {
req.session.userId = 42; // Server State Saving
res.send('logged in');
});
app.get('/profile', (req, res) => {
const userId = req.session.userId; // Depends on the server status
res.json({ id: userId, name: 'Alice' });
});
// Stateless:Include authentication information with every request(RESTful)
app.get('/profile', (req, res) => {
const userId = verifyToken(req.headers.authorization);
res.json({ id: userId, name: 'Alice' });
});
3. CRUD and HTTP Method Mappings
(1) Standard mapping relationship
The core idea of REST is to use HTTP methods to express the intent of operations on resources, rather than embedding action verbs in URLs.
| CRUD Operations | HTTP Methods | Paths | Idempotency | Security |
|---|---|---|---|---|
| Create | POST | /tasks |
No | No |
| Read (List) | GET | /tasks |
Yes | Yes |
| Read (Single) | GET | /tasks/42 |
Yes | Yes |
| Update (Full) | PUT | /tasks/42 |
Yes | No |
| Update (partial) | PATCH | /tasks/42 |
No | No |
| Delete | DELETE | /tasks/42 |
Yes | No |
(2) A Detailed Explanation of Idempotency
Idempotence means that executing the same request once has the same effect as executing it multiple times. GET, PUT, and DELETE are idempotent, while POST and PATCH are not.
▶ Example: Differences in Idempotency Between PUT and POST
// POST: Creates a new resource on every call (Non-idempotent)
// 1st POST /tasks → Create id=1
// 2nd POST /tasks → Create id=2
app.post('/tasks', (req, res) => {
const task = { id: nextId++, ...req.body };
tasks.push(task);
res.status(201).json(task);
});
// PUT: Replaces the same resource on every call (Idempotent)
// 1st PUT /tasks/1 → Replace id=1
// 2nd PUT /tasks/1 → Replace id=1 (The results are the same)
app.put('/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: 'Not found' });
tasks[idx] = { id: parseInt(req.params.id), ...req.body };
res.json(tasks[idx]);
});
▶ Example: PATCH Partial Update
// PATCH:Modify only the fields provided
app.patch('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Not found' });
Object.assign(task, req.body);
res.json(task);
});
// Request:Modify only status Field
// PATCH /tasks/1 {"status": "done"}
// Raw Data:{"id":1,"title":"Learn REST","status":"pending"}
// Results:{"id":1,"title":"Learn REST","status":"done"}
4. URL Design Guidelines
(1) Core Rules
RESTful URL design follows a set of conventions that make APIs intuitive and easy to read.
| Rule | Correct ✅ | Incorrect ❌ |
|---|---|---|
| Use nouns, not verbs | GET /tasks |
GET /getTasks |
| Use the plural, not the singular | /tasks |
/task |
| Representing Relationships with Nesting | /users/42/tasks |
/tasksByUser?userId=42 |
| No more than 3 levels | /users/42/tasks/1 |
/orgs/1/teams/2/users/42/tasks |
| Filter by query parameters | /tasks?status=done |
/doneTasks |
| Using kebab-case | /task-item |
/taskItems |
(2) Design of Nested Resources
Nested resources indicate a hierarchical relationship. Use nested paths when a child resource cannot exist independently of its parent resource.
▶ Example: URL Design for a Task Management System
# Mission Resources
GET /tasks # Get the task list
POST /tasks # Create a New Task
GET /tasks/42 # Get a Single Task
PUT /tasks/42 # Full Update Task
PATCH /tasks/42 # Partial Update Task
DELETE /tasks/42 # Delete Task
# Comments on the Assignment(Nested Resources)
GET /tasks/42/comments # Get a Task42List of comments
POST /tasks/42/comments # For the mission42Add a comment
GET /tasks/42/comments/7 # Get a Task42Comments on7
DELETE /tasks/42/comments/7 # Delete Comment7
# Filtering and Pagination
GET /tasks?status=done&page=2&limit=20
GET /tasks?sort=-created_at # Sort by creation date in reverse chronological order
▶ Example: Common Uses of URL Query Parameters
app.get('/tasks', (req, res) => {
let result = [...tasks];
// Filter
if (req.query.status) {
result = result.filter(t => t.status === req.query.status);
}
// Sort
if (req.query.sort) {
const field = req.query.sort.startsWith('-')
? req.query.sort.slice(1)
: req.query.sort;
const order = req.query.sort.startsWith('-') ? -1 : 1;
result.sort((a, b) => (a[field] > b[field] ? order : -order));
}
// Pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const start = (page - 1) * limit;
result = result.slice(start, start + limit);
res.json({
data: result,
page,
limit,
total: tasks.length
});
});
5. Choosing HTTP Status Codes
(1) Classification and Selection of Status Codes
HTTP status codes are key signals for communication between REST APIs and clients. By choosing the correct status code, clients can accurately understand the result of a request.
| Scenario | Status Code | Meaning | Description |
|---|---|---|---|
| Resource retrieved successfully | 200 OK | Request successful | Returned when GET/PUT/PATCH is successful |
| Resource created successfully | 201 Created | Resource created | Returned upon successful POST; should include a Location header |
| Resource successfully deleted | 204 No Content | No content | Returned when DELETE is successful; no response body |
| Invalid request parameters | 400 Bad Request | Client request syntax error | Missing required fields, invalid format |
| Not authenticated | 401 Unauthorized | No authentication information provided | Missing or invalid token |
| No Permission | 403 Forbidden | Authenticated but No Permission | Regular User Accessing Admin Interface |
| Resource does not exist | 404 Not Found | The requested resource does not exist | The resource with this ID was not found |
| Server Error | 500 Internal Server Error | Internal Server Error | Uncaught Exception |
(2) Common Mistakes: Incorrect Use of Status Codes
▶ Example: Correct Use of Status Codes
// Create a Resource → 201 + Location
app.post('/tasks', (req, res) => {
const task = { id: nextId++, ...req.body };
tasks.push(task);
res.status(201)
.location(`/tasks/${task.id}`)
.json(task);
});
// Delete Resource → 204(Non-responsive body)
app.delete('/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: 'Not found' });
tasks.splice(idx, 1);
res.status(204).end();
});
// Verification Failed → 400 + Error Details
app.post('/tasks', (req, res) => {
if (!req.body.title) {
return res.status(400).json({
error: 'Validation failed',
details: [{ field: 'title', message: 'Title is required' }]
});
}
// ...
});
6. Request and Response Formats
(1) JSON Specification Conventions
| Convention | Standard | Example |
|---|---|---|
| Field naming | camelCase | createdAt, taskId |
| Date Format | ISO 8601 | 2025-07-03T10:30:00Z |
| List Response | Includes data + pagination information | {"data": [...], "total": 100} |
| Error Response | Includes error + details | {"error": "Not found", "details": [...]} |
| Handling Null Values | Use null instead of omitting the field |
{"description": null} |
| ID Type | String (to avoid precision issues) | {"id": "42"} |
▶ Example: Standardized list response
app.get('/tasks', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const start = (page - 1) * limit;
const data = tasks.slice(start, start + limit);
res.json({
data,
pagination: {
page,
limit,
total: tasks.length,
totalPages: Math.ceil(tasks.length / limit)
}
});
});
// Response Example
{
"data": [
{
"id": "1",
"title": "Learn REST",
"status": "pending",
"createdAt": "2025-07-03T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"totalPages": 1
}
}
▶ Example: Standardized Error Response
// Unified Error Handling Middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
details: err.details || [],
requestId: req.id,
timestamp: new Date().toISOString()
});
});
// Custom Error Classes
class ApiError extends Error {
constructor(status, message, details = []) {
super(message);
this.status = status;
this.details = details;
}
}
// Usage
app.get('/tasks/:id', (req, res, next) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return next(new ApiError(404, 'Task not found', [
{ field: 'id', message: `No task with id ${req.params.id}` }
]));
}
res.json(task);
});
// Examples of Error Responses
{
"error": "Task not found",
"details": [
{ "field": "id", "message": "No task with id 999" }
],
"requestId": "req-a1b2c3",
"timestamp": "2025-07-03T10:30:00Z"
}
7. API Versioning Strategy
(1) Comparison of Three Mainstream Strategies
| Strategy | Example | Advantages | Disadvantages | Applicable Scenarios |
|---|---|---|---|---|
| URL Path | /api/v1/tasks |
Intuitive, can be tested in a browser | Longer URLs, controversial among purist REST advocates | Most public APIs |
| Request Header | Accept: application/vnd.myapi.v1+json |
Clean, pure RESTful URL | Not intuitive, difficult to debug | Pursues REST purity |
| Query Parameter | /api/tasks?version=1 |
Simplest | Easily overlooked; complex caching strategy | Internal APIs, simple projects |
(2) Best Practices for Versioning
- Starting with v1, do not omit the version number
- Upgrade to a major version only when there are breaking changes
- Older versions will be supported for at least 6 months
- Specify the current version in the response header
▶ Example: Implementing URL Path Versioning
// Routing Structure
// /api/v1/tasks → v1 Logic
// /api/v2/tasks → v2 Logic
const express = require('express');
const app = express();
// v1 Routing
const v1Router = express.Router();
v1Router.get('/tasks', (req, res) => {
res.json({ data: tasks, version: 'v1' }); // v1 Return Format
});
// v2 Routing(Response Format Upgrade)
const v2Router = express.Router();
v2Router.get('/tasks', (req, res) => {
res.json({ // v2 Return Format(Includes pagination)
data: tasks,
pagination: { page: 1, total: tasks.length }
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Response Header Version
app.use('/api/v2', (req, res, next) => {
res.setHeader('X-API-Version', '2.0');
next();
});
8. REST Maturity Model
(1) Richardson's Maturity Model
Leonard Richardson proposed a model for measuring the RESTful maturity of an API:
graph TD
L0["Level 0: Single endpoint<br/>HTTP as a tunnel<br/>e.g. POST /api {action: getTasks}"]
L1["Level 1: Resource URLs<br/>One URL per resource<br/>e.g. POST /tasks, POST /users"]
L2["Level 2: HTTP Methods<br/>GET/POST/PUT/DELETE<br/>e.g. GET /tasks, DELETE /tasks/1"]
L3["Level 3: HATEOAS<br/>Responses contain hyperlinks<br/>e.g. Response includes next, self links"]
L0 --> L1 --> L2 --> L3
style L0 fill:#ff6b6b,color:#fff
style L1 fill:#ffa502,color:#fff
style L2 fill:#2ed573,color:#fff
style L3 fill:#1e90ff,color:#fff
| Level | Characteristics | Sample Request | Sample Response |
|---|---|---|---|
| Level 0 | HTTP tunnel, single URL | POST /api {"action":"getTasks"} |
{"tasks": [...]} |
| Level 1 | Resource separation; any method allowed | POST /tasks |
{"tasks": [...]} |
| Level 2 | Semantically Correct HTTP | GET /tasks |
200 {"data": [...]} |
| Level 3 | HATEOAS Hypermedia | GET /tasks/1 |
Includes _links Navigation |
(2) A Detailed Explanation of HATEOAS
HATEOAS (Hypermedia as the Engine of Application State) requires that responses include links to relevant operations, so clients do not need to hard-code URLs.
▶ Example: Level 3 — Response with a HATEOAS link
app.get('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Not found' });
res.json({
...task,
_links: {
self: { href: `/tasks/${task.id}`, method: 'GET' },
update: { href: `/tasks/${task.id}`, method: 'PUT' },
delete: { href: `/tasks/${task.id}`, method: 'DELETE' },
assign: { href: `/tasks/${task.id}/assignee`, method: 'POST' },
comments: { href: `/tasks/${task.id}/comments`, method: 'GET' }
}
});
});
// Response
{
"id": "1",
"title": "Learn REST",
"status": "pending",
"createdAt": "2025-07-03T10:30:00Z",
"_links": {
"self": { "href": "/tasks/1", "method": "GET" },
"update": { "href": "/tasks/1", "method": "PUT" },
"delete": { "href": "/tasks/1", "method": "DELETE" },
"assign": { "href": "/tasks/1/assignee", "method": "POST" },
"comments": { "href": "/tasks/1/comments", "method": "GET" }
}
}
9. Comprehensive Example: Complete Design of the Task Management API
Alice's team designed a complete RESTful API for the task management system, covering everything from resource definitions to error handling.
▶ Example: Complete Task Management API
(1) Resource Definition
| Resource | Path | Description |
|---|---|---|
| Task Collection | /api/v1/tasks |
All Tasks |
| Single Task | /api/v1/tasks/:id |
Specified Task |
| Task Comments | /api/v1/tasks/:id/comments |
Comments on a Specific Task |
| Task Tag | /api/v1/tasks/:id/tags |
Tag for a specific task |
(2) Method Mapping and Request/Response
const express = require('express');
const app = express();
app.use(express.json());
let tasks = [
{ id: 1, title: 'Design database schema', status: 'done', priority: 'high', createdAt: '2025-07-01T08:00:00Z' },
{ id: 2, title: 'Implement REST API', status: 'in-progress', priority: 'high', createdAt: '2025-07-02T09:00:00Z' }
];
let nextId = 3;
// GET /api/v1/tasks — Get the task list
app.get('/api/v1/tasks', (req, res) => {
const { status, priority, page = 1, limit = 20 } = req.query;
let result = [...tasks];
if (status) result = result.filter(t => t.status === status);
if (priority) result = result.filter(t => t.priority === priority);
const start = (page - 1) * limit;
const data = result.slice(start, start + Number(limit));
res.json({
data,
pagination: {
page: Number(page),
limit: Number(limit),
total: result.length,
totalPages: Math.ceil(result.length / Number(limit))
}
});
});
// GET /api/v1/tasks/:id — Get a Single Task
app.get('/api/v1/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
res.json({
data: task,
_links: {
self: { href: `/api/v1/tasks/${task.id}` },
update: { href: `/api/v1/tasks/${task.id}`, method: 'PUT' },
delete: { href: `/api/v1/tasks/${task.id}`, method: 'DELETE' },
comments: { href: `/api/v1/tasks/${task.id}/comments` }
}
});
});
// POST /api/v1/tasks — Create a Task
app.post('/api/v1/tasks', (req, res) => {
const { title, priority } = req.body;
if (!title) {
return res.status(400).json({
error: 'Validation failed',
details: [{ field: 'title', message: 'Title is required' }],
timestamp: new Date().toISOString()
});
}
const task = {
id: nextId++,
title,
status: 'pending',
priority: priority || 'medium',
createdAt: new Date().toISOString()
};
tasks.push(task);
res.status(201).location(`/api/v1/tasks/${task.id}`).json({ data: task });
});
// PUT /api/v1/tasks/:id — Full Update
app.put('/api/v1/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
const { title, status, priority } = req.body;
if (!title || !status) {
return res.status(400).json({
error: 'Validation failed',
details: [
...(!title ? [{ field: 'title', message: 'Title is required' }] : []),
...(!status ? [{ field: 'status', message: 'Status is required' }] : [])
],
timestamp: new Date().toISOString()
});
}
tasks[idx] = { id: tasks[idx].id, title, status, priority: priority || 'medium', createdAt: tasks[idx].createdAt };
res.json({ data: tasks[idx] });
});
// PATCH /api/v1/tasks/:id — Partial Update
app.patch('/api/v1/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
Object.assign(task, req.body);
res.json({ data: task });
});
// DELETE /api/v1/tasks/:id — Delete Task
app.delete('/api/v1/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
tasks.splice(idx, 1);
res.status(204).end();
});
app.listen(3000, () => console.log('Task API running on port 3000'));
(3) Quick Reference for Requests and Responses
# Create a Task
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Write documentation","priority":"high"}'
# Get the task list(Filter + Pagination)
curl http://localhost:3000/api/v1/tasks?status=pending&page=1&limit=10
# Partial Update
curl -X PATCH http://localhost:3000/api/v1/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status":"done"}'
# Delete Task
curl -X DELETE http://localhost:3000/api/v1/tasks/2
// POST Created successfully → 201
Status: 201 Created
Location: /api/v1/tasks/3
{ "data": { "id": 3, "title": "Write documentation", "status": "pending", "priority": "high", "createdAt": "2025-07-03T10:30:00Z" } }
// PATCH Update successful → 200
{ "data": { "id": 1, "title": "Design database schema", "status": "done", "priority": "high", "createdAt": "2025-07-01T08:00:00Z" } }
// DELETE Success → 204
Status: 204 No Content
(empty body)
// 404 Error
{ "error": "Task not found", "details": [{ "field": "id", "message": "No task with id 999" }], "timestamp": "2025-07-03T10:30:00Z" }
// 400 Validation Error
{ "error": "Validation failed", "details": [{ "field": "title", "message": "Title is required" }], "timestamp": "2025-07-03T10:30:00Z" }
❓ FAQ
/api/v1/) is the most intuitive; it can be tested directly in a browser and is the choice for most public APIs. Request header versioning is more RESTful but more complicated to debug. Query parameters are the simplest but are easily overlooked. URL path versioning is recommended for beginners.Content-Type header./users/42/tasks/1/comments/5 is too deep, you can change it to /comments/5 or /tasks/1/comments/5./tasks/batch with an array to create records; using a PATCH request /tasks with an array to update records in bulk; and using a DELETE request /tasks?ids=1,2,3 to delete records in bulk. Custom endpoints must be clearly documented.📖 Summary
- Key Concepts and How to Apply Them
- 1 Core Concepts and Usage of REST Architecture Principles
- 2 Core Concepts and Usage of CRUD and HTTP Method Mapping
- 3 Core Concepts and Usage of URL Design Guidelines
- 4 Key Concepts and Usage of HTTP Status Codes
- 5 Core Concepts and Usage of Request and Response Formats
- 6 Core Concepts and Usage of API Versioning Strategies
- 7 Core Concepts and Usage of the REST Maturity Model
📝 Exercises
- Complete all the code examples in this lesson and make sure each one runs correctly.
- Modify the comprehensive example and add your own extensions
- Review the official documentation, identify 1–2 APIs not covered in this lesson, and write test code for them.
- Reflection: How would you apply what you’ve learned in this lesson to a real-world project?
- Try to combine what you’ve learned in this lesson with material from previous lessons to build a small project.