Building an HTTP Server
Node.js was built with server-side JavaScript in mind, and its built-in http module makes creating web servers straightforward. In this tutorial, you’ll build HTTP servers from scratch, handle different routes, and process client requests.
Your First HTTP Server
The http module provides everything you need to create a server. Here’s a minimal example:
import { createServer } from 'http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000/');
});
Run this with node server.js and visit http://localhost:3000 in your browser. The server responds with “Hello, World!” for every request.
The callback function receives two objects:
req(request) — contains details about the incoming requestres(response) — lets you send data back to the client
Why the First Server Is Small
The smallest possible server is a good teaching tool because it removes every distraction except the request-response loop. You can see exactly when the callback runs, when headers are written, and when the response is closed. That matters because most HTTP bugs in Node.js are not about syntax; they are about timing, status codes, and forgetting to finish the response. Starting with one route also makes it obvious that everything else in the tutorial is just a controlled variation on the same pattern.
Understanding the Request Object
The req object contains all the information about what the client asked for:
import { createServer } from 'http';
const server = createServer((req, res) => {
console.log('Method:', req.method); // GET, POST, PUT, DELETE
console.log('URL:', req.url); // The path requested
console.log('Headers:', req.headers); // All request headers
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Request received');
});
server.listen(3000);
Common req properties:
req.method— HTTP verb (GET, POST, etc.)req.url— The path and query stringreq.headers— Object containing all headers
Thinking About Routes as Decisions
Without a framework, routing is just a set of conditions that decide which response should go out. That is useful to learn because it shows that frameworks do not perform magic. They organize the same logic into reusable helpers. When you write the conditions yourself, you get a clearer sense of why the path and method both matter. A GET /api/users request and a POST /api/users request may share a path, but they are still different operations.
Handling Different Routes
Most applications need to handle multiple routes. Here’s how to do it:
import { createServer } from 'http';
const server = createServer((req, res) => {
const pathname = req.url.split('?')[0];
if (pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Welcome Home</h1>');
}
else if (pathname === '/about' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>About Us</h1>');
}
else if (pathname === '/api/users' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ users: [] }));
}
else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
server.listen(3000);
This handles three routes plus a 404 fallback.
Reading the Body as a Stream
Request bodies do not arrive all at once. Node.js gives you chunks, and only after the end event do you know the full payload has been received. That is why body parsing always starts by collecting bytes, then decoding them once the stream is complete. If you try to parse too early, you can end up with partial JSON or incomplete form data. Treating the body as a stream keeps the server reliable even when requests are larger than a single chunk.
Reading Request Body Data
For POST and PUT requests, you need to read the request body:
import { createServer } from 'http';
const server = createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/submit') {
// Collect chunks of data
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = JSON.parse(Buffer.concat(chunks).toString());
console.log('Received:', body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, received: body }));
} else {
res.writeHead(404);
res.end();
}
});
server.listen(3000);
Test it with curl:
curl -X POST http://localhost:3000/submit \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "age": 30}'
Setting Response Headers
Proper headers are essential for correct behavior:
import { createServer } from 'http';
const server = createServer((req, res) => {
// JSON response
if (req.url === '/api/data') {
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
});
res.end(JSON.stringify({ message: 'Here is your data' }));
}
// HTML with custom headers
else if (req.url === '/') {
res.writeHead(200, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache'
});
res.end('<html><body><h1>Hello!</h1></body></html>');
}
// Redirect
else if (req.url === '/old-page') {
res.writeHead(301, { 'Location': '/new-page' });
res.end();
}
else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(3000);
Why Headers Matter
Headers are where HTTP turns from raw data into a protocol the browser can understand. A content type tells the client how to interpret the body, a cache header changes how long the response can be reused, and a redirect header tells the browser to try a different URL. When you write Node.js servers directly, you are responsible for these decisions yourself, which is a good way to learn how much frameworks usually automate.
Status Codes and Early Returns
Status codes are part of the contract between your server and the client, so it helps to choose them deliberately. 200 says the request succeeded, 404 says the route is missing, and 405 says the route exists but the method is not allowed. When you pair status codes with early returns, each branch can handle one outcome and finish the response immediately. That keeps the handler easy to scan and makes it obvious when the response is complete.
Handling Errors Without Losing the Response
In a small server, it is tempting to assume that every branch will succeed. In real code, parsing can fail, JSON can be invalid, or a client can disconnect early. A good habit is to keep the error path close to the code that can fail and make sure the response is always ended or aborted cleanly. That keeps the server from hanging on requests that never complete and makes debugging much less frustrating.
Growing the Example
Once this pattern feels familiar, the next step is to factor repeated logic into helpers. You might add a router function, a JSON body parser, or a small response helper that sets common headers. Those additions should feel like a refactoring, not a new idea. The core lesson stays the same: Node.js HTTP servers are just request handlers with explicit control over the response.
Why Manual Control Is Worth Learning
Frameworks are great, but they all sit on top of the same primitives. Learning the raw http API gives you a clearer picture of what a framework is doing for you and what it is adding on top. That knowledge pays off whenever you debug a header issue, inspect a streaming response, or need to build something small without pulling in a larger toolchain. The core module is simple on purpose, and that simplicity makes it a strong teaching tool.
The bigger idea is that you can always trace the request path from start to finish.
Keep the server flow explicit
An HTTP server is easier to understand when each branch does one thing and then exits. Read the request, choose the response, set the status, and end the body. That flow keeps the handler predictable and makes it much easier to see where a response comes from. It also makes later refactors safer because the control flow stays obvious even when you split helpers out of the main callback.
When you grow past one or two routes, the same habit still helps. A small router or a handful of helper functions can keep the file from turning into a wall of conditions. The point is not to hide the HTTP details. It is to keep them organized enough that you can still reason about the server without tracing every line in your head.
Working with HTTPS
For secure servers, use the https module with SSL certificates:
import { createServer } from 'https';
import { readFileSync } from 'fs';
const options = {
key: readFileSync('private-key.pem'),
cert: readFileSync('certificate.pem')
};
const server = createServer(options, (req, res) => {
res.writeHead(200);
res.end('Secure connection established');
});
server.listen(443, () => {
console.log('HTTPS server running on port 443');
});
Summary
You’ve learned how to:
- Create a basic HTTP server with
createServer() - Access request method, URL, and headers
- Handle different routes with conditional logic
- Read POST request bodies
- Set response headers for JSON, HTML, and redirects
- Set up HTTPS for secure connections
The http module is powerful enough for many applications, but for production use, frameworks like Express provide nicer APIs and more features.