REST APIs are one of the most common ways for modern applications to communicate with backend services. Whether you are building a React frontend, mobile application, SaaS platform, or internal tool, a well-designed API becomes the foundation for reliable data exchange.
Node.js makes REST API development approachable because you can use JavaScript across the entire stack. Its asynchronous runtime, lightweight architecture, and large ecosystem make it a practical choice for building APIs that handle everything from simple CRUD operations to production workloads.
Building a Production-Ready REST API with Node.js
A REST API organizes application functionality around resources and standard HTTP methods. GET is typically used to retrieve data, POST creates a resource, PUT replaces an existing resource, PATCH updates part of a resource, and DELETE removes it. Good API design also uses meaningful status codes such as 200 for successful requests, 201 for resource creation, 400 for invalid input, 404 when a resource cannot be found, and 500 for unexpected server errors.
For this example, we will build a small task management API using only Node.js built-in modules. The API will expose endpoints for listing tasks, creating tasks, retrieving an individual task, updating a task, and deleting a task. Keeping the implementation dependency-free makes it easier to understand what happens underneath frameworks such as Express.
A useful production mindset is to separate responsibilities even when the project is small. Request parsing, routing, validation, business logic, and response formatting should have clear boundaries. The example below keeps everything in one file for learning purposes, while still demonstrating validation, HTTP methods, status codes, JSON responses, error handling, and structured logging.
const http = require("http");
const { URL } = require("url");
const PORT = 3000;
// In-memory data store for demonstration purposes.
// A production API would normally use PostgreSQL, MongoDB, or another database.
let tasks = [
{ id: 1, title: "Learn Node.js REST APIs", completed: false },
{ id: 2, title: "Build a production project", completed: false }
];
let nextId = 3;
// Send a consistent JSON response to the client.
function sendJson(res, statusCode, data) {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
});
res.end(body);
}
// Read and parse a JSON request body.
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", chunk => {
body += chunk.toString();
});
req.on("end", () => {
if (!body) return resolve({});
try {
resolve(JSON.parse(body));
} catch (error) {
reject(new Error("Request body must contain valid JSON"));
}
});
req.on("error", reject);
});
}
// Validate the fields accepted by the task resource.
function validateTask(payload) {
if (!payload.title || typeof payload.title !== "string") {
return "title is required and must be a string";
}
if (payload.completed !== undefined && typeof payload.completed !== "boolean") {
return "completed must be a boolean";
}
return null;
}
const server = http.createServer(async (req, res) => {
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = requestUrl.pathname;
const method = req.method;
console.log(`\\n[REQUEST] ${method} ${pathname}`);
try {
// GET /tasks - Return every task.
if (method === "GET" && pathname === "/tasks") {
console.log("[STEP 1] Fetching all tasks");
return sendJson(res, 200, { success: true, data: tasks });
}
// GET /tasks/:id - Return one task.
const taskMatch = pathname.match(/^\\/tasks\\/(\\d+)$/);
if (method === "GET" && taskMatch) {
const id = Number(taskMatch[1]);
console.log(`[STEP 1] Looking for task with ID ${id}`);
const task = tasks.find(item => item.id === id);
if (!task) {
console.log("[STEP 2] Task not found");
return sendJson(res, 404, {
success: false,
error: "Task not found"
});
}
console.log("[STEP 2] Task found successfully");
return sendJson(res, 200, { success: true, data: task });
}
// POST /tasks - Create a new task.
if (method === "POST" && pathname === "/tasks") {
console.log("[STEP 1] Reading request body");
const payload = await readBody(req);
console.log("[STEP 2] Validating task data");
const validationError = validateTask(payload);
if (validationError) {
console.log(`[VALIDATION] ${validationError}`);
return sendJson(res, 400, {
success: false,
error: validationError
});
}
const task = {
id: nextId++,
title: payload.title.trim(),
completed: payload.completed ?? false
};
tasks.push(task);
console.log(`[STEP 3] Created task with ID ${task.id}`);
return sendJson(res, 201, { success: true, data: task });
}
// PATCH /tasks/:id - Update an existing task.
if (method === "PATCH" && taskMatch) {
const id = Number(taskMatch[1]);
console.log(`[STEP 1] Updating task ${id}`);
const task = tasks.find(item => item.id === id);
if (!task) {
return sendJson(res, 404, {
success: false,
error: "Task not found"
});
}
const payload = await readBody(req);
console.log("[STEP 2] Validating update payload");
if (payload.title !== undefined && typeof payload.title !== "string") {
return sendJson(res, 400, {
success: false,
error: "title must be a string"
});
}
if (payload.completed !== undefined && typeof payload.completed !== "boolean") {
return sendJson(res, 400, {
success: false,
error: "completed must be a boolean"
});
}
if (payload.title !== undefined) task.title = payload.title.trim();
if (payload.completed !== undefined) task.completed = payload.completed;
console.log("[STEP 3] Task updated successfully");
return sendJson(res, 200, { success: true, data: task });
}
// DELETE /tasks/:id - Delete a task.
if (method === "DELETE" && taskMatch) {
const id = Number(taskMatch[1]);
console.log(`[STEP 1] Deleting task ${id}`);
const index = tasks.findIndex(item => item.id === id);
if (index === -1) {
return sendJson(res, 404, {
success: false,
error: "Task not found"
});
}
const deletedTask = tasks.splice(index, 1)[0];
console.log("[STEP 2] Task deleted successfully");
return sendJson(res, 200, {
success: true,
data: deletedTask
});
}
// Return a clear response for unsupported routes or methods.
console.log("[ROUTE] Endpoint not found");
return sendJson(res, 404, {
success: false,
error: "Route not found"
});
} catch (error) {
// Prevent unexpected errors from crashing the server.
console.error("[ERROR]", error.message);
return sendJson(res, 500, {
success: false,
error: "Internal server error"
});
}
});
server.listen(PORT, () => {
console.log("========================================");
console.log(`REST API running at http://localhost:${PORT}`);
console.log("Available endpoints:");
console.log("GET /tasks");
console.log("GET /tasks/:id");
console.log("POST /tasks");
console.log("PATCH /tasks/:id");
console.log("DELETE /tasks/:id");
console.log("========================================");
});
Conclusion
Building a REST API with Node.js is less about writing routes and more about establishing predictable contracts between clients and servers. Consistent response formats, meaningful HTTP status codes, input validation, clear resource naming, and defensive error handling make APIs much easier to consume and maintain.
The built-in HTTP module is excellent for understanding the fundamentals, but production applications often use frameworks such as Express or Fastify to reduce boilerplate and provide mature middleware ecosystems. As an application grows, the next improvements should include persistent database storage, authentication, rate limiting, request validation, centralized error handling, logging, testing, and API documentation.
The important lesson is to learn the HTTP and REST concepts underneath the framework. Once those fundamentals are clear, moving from a small Node.js server to a scalable API architecture becomes much easier.
Top comments (0)