Express.js: I Stopped Fighting Node's HTTP Module and Everything Got Easier

I built my first Node.js server using the built-in http module. It worked. It also made me want to close my laptop and go outside. Not because the code was hard, but because every tiny thing required writing the same boilerplate over and over. Want to check if the request is a GET to /users? You write an if statement. Want to parse the body of a POST request? You manually collect chunks of data from a stream. Want to send JSON back? You set headers by hand and call res.end() with a stringified object.
Here is what a basic server looks like with just Node:
const http = require('http');
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/hello') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'hi there' }));
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
}
});
server.listen(3000, () => {
console.log('running on port 3000');
});
Two routes and the code is already getting annoying. Imagine adding fifteen more. Imagine adding POST body parsing to each one. Imagine doing error handling across all of them. I tried. I got about four routes deep before the if/else chain became unreadable.
Express exists because nobody wants to write that.
What Express actually is
Express is a library that sits on top of Node's http module. It does not replace it. Under the hood, Express still creates an http.Server. What it gives you is a cleaner way to define routes, read request data, and send responses without manually wiring everything together yourself.
Install it and set up a project:
mkdir my-express-app && cd my-express-app
npm init -y
npm install express
Now the same server from above, rewritten:
const express = require('express');
const app = express();
app.get('/hello', (req, res) => {
res.json({ message: 'hi there' });
});
app.listen(3000, () => {
console.log('running on port 3000');
});
No writeHead, or JSON.stringify. No checking req.method and req.url manually. One line defines the route, the method, and the handler. res.json() sets the content type and stringifies the object for you.
That difference gets wider the more routes you add.
The request comes in. Express checks its list of registered routes. If it finds a match for both the HTTP method and the URL path, it runs the handler function you gave it. If nothing matches, Express sends a 404 by default. You do not have to write that yourself.
Handling GET requests
GET requests are how clients ask for data. When you type a URL in your browser, that is a GET request. When a frontend calls fetch('/api/users'), that is a GET request.
const express = require('express');
const app = express();
const users = [
{ id: 1, name: 'Priya', email: 'priya@example.com' },
{ id: 2, name: 'Ravi', email: 'ravi@example.com' },
{ id: 3, name: 'Meera', email: 'meera@example.com' },
];
// get all users
app.get('/users', (req, res) => {
res.json(users);
});
app.listen(3000, () => console.log('running on 3000'));
Hit http://localhost:3000/users in your browser and you get:
[
{ "id": 1, "name": "Priya", "email": "priya@example.com" },
{ "id": 2, "name": "Ravi", "email": "ravi@example.com" },
{ "id": 3, "name": "Meera", "email": "meera@example.com" }
]
Now say you want a single user by their ID. Express lets you put a colon in the URL path to create a route parameter:
// get one user by id
app.get('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({ error: 'user not found' });
}
res.json(user);
});
req.params.id pulls the value from the URL. A request to /users/2 gives you req.params.id as the string "2". You parse it to a number, look it up, and either return the user or a 404. That :id syntax is called a route parameter, and you can have multiple in one path: /users/:userId/orders/:orderId would give you both req.params.userId and req.params.orderId.
One thing that tripped me up early: route parameters always come back as strings. Even if the URL says /users/2, req.params.id is "2", not 2. Forgetting to parse it leads to comparison bugs that are annoying to track down because "2" === 2 is false in JavaScript.
Handling POST requests
POST requests send data to the server. A signup form, a new order, a comment being submitted. The data lives in the request body, and Express does not parse it automatically. You need to tell it how.
app.use(express.json());
That one line is middleware. It tells Express to parse incoming JSON bodies and attach the result to req.body. Without it, req.body is undefined and you will stare at your code wondering why nothing works. I spent 20 minutes on this the first time. Add this line near the top of your file, before your route definitions.
const express = require('express');
const app = express();
app.use(express.json()); // parse JSON bodies
const users = [
{ id: 1, name: 'Priya', email: 'priya@example.com' },
{ id: 2, name: 'Ravi', email: 'ravi@example.com' },
];
let nextId = 3;
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const newUser = { id: nextId++, name, email };
users.push(newUser);
res.status(201).json(newUser);
});
app.listen(3000, () => console.log('running on 3000'));
Test it with curl from your terminal:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Aditya", "email": "aditya@example.com"}'
Response:
{ "id": 3, "name": "Aditya", "email": "aditya@example.com" }
The 201 status code means "created." You could send 200 and it would work fine, but 201 tells the client specifically that a new resource was created. Using the right status codes is a habit worth building early. 200 means OK. 201 means created. 400 means the client sent bad data. 404 means not found. 500 means the server broke. Most real APIs use these consistently, and your code becomes easier to debug when the status code already tells you what category of problem you are looking at.
Here is what the full flow looks like when a POST request comes in:
Sending different types of responses
res.json() is the one you will use most, but Express has a few other response methods worth knowing.
// send plain text
app.get('/health', (req, res) => {
res.send('OK');
});
// send JSON with a status code
app.get('/status', (req, res) => {
res.status(200).json({ status: 'running', uptime: process.uptime() });
});
// send just a status code with no body
app.delete('/users/:id', (req, res) => {
// ... delete logic here
res.sendStatus(204); // 204 means "no content" - deleted successfully, nothing to return
});
// redirect to another URL
app.get('/old-page', (req, res) => {
res.redirect('/new-page');
});
res.send() is smart about what you give it. Pass a string, it sets the content type to text/html. Pass an object, it behaves like res.json(). Pass a Buffer, it sets the type to application/octet-stream. I would recommend being explicit and using res.json() for objects and res.send() for strings, rather than relying on the auto-detection. It makes your intent obvious when someone else reads the code later.
One gotcha: do not call res.json() or res.send() twice in the same handler. Express will throw an error that says "Cannot set headers after they are sent to the client." This usually happens when you forget a return statement before sending an error response:
// broken - sends two responses
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
res.status(404).json({ error: 'not found' });
// missing return! code keeps running
}
res.json(user); // crashes here because a response was already sent
});
// fixed
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'not found' }); // return stops execution
}
res.json(user);
});
That return before res.status(404) is easy to forget and hard to debug if you do not know what the error message means.
Putting it all together
Here is a complete working server with GET and POST routes, input validation, proper status codes, and the JSON parsing middleware. You can copy this into a file called server.js and run it with node server.js:
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
const users = [
{ id: 1, name: 'Priya', email: 'priya@example.com' },
{ id: 2, name: 'Ravi', email: 'ravi@example.com' },
];
let nextId = 3;
// get all users
app.get('/users', (req, res) => {
res.json(users);
});
// get single user
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'user not found' });
res.json(user);
});
// create a user
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const newUser = { id: nextId++, name, email };
users.push(newUser);
res.status(201).json(newUser);
});
// health check
app.get('/health', (req, res) => {
res.send('OK');
});
app.listen(PORT, () => {
console.log(`server running on http://localhost:${PORT}`);
});
Test it with these curl commands:
# get all users
curl http://localhost:3000/users
# get one user
curl http://localhost:3000/users/1
# create a user
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Aditya", "email": "aditya@example.com"}'
# try creating without required fields
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Aditya"}'
A few habits worth picking up now
Use nodemon during development. It restarts your server automatically when you save a file. Without it you will be pressing Ctrl+C and typing node server.js hundreds of times a day.
npm install --save-dev nodemon
Add a script to your package.json:
{
"scripts": {
"dev": "nodemon server.js"
}
}
Then run npm run dev instead of node server.js.
Keep your route handlers short. If a handler is doing validation, database queries, and sending emails all in one function, it gets painful to maintain fast. As your app grows, pull logic into separate functions or files. You do not need to do this on day one, but keep it in mind as the file gets longer.
Always validate incoming data. Never trust req.body to contain what you expect. Check for missing fields. Check types when it matters. A missing return after sending an error response will crash your server, and bad input from a client should never be able to do that.
What comes next
This covers the basics: setting up Express, handling GET and POST, sending responses, and avoiding the common early mistakes. The next piece you will run into is middleware, which is how Express handles things like authentication, logging, and error handling across routes without duplicating code everywhere. That will be its own post.
For now, build something small. A to-do list API. A bookmarks server. Something where you have two or three routes that create and read data. The concepts stick faster when you are solving a problem you actually care about, even a tiny one.





