Skip to main content

Command Palette

Search for a command to run...

Creating Routes and Handling Requests with Express.js: A Practical Guide

Updated
7 min read
Creating Routes and Handling Requests with Express.js: A Practical Guide

Creating Routes and Handling Requests with Express.js

Audience: This post assumes you know basic JavaScript and have Node.js installed. No prior Express experience required.

TL;DR: Express.js wraps Node's http module with a routing layer and middleware system. You define routes with app.get(), app.post(), etc., attach handler functions, and send responses with res.json() or res.send(). The result is readable, maintainable server code without manual URL parsing.


Problem

Node.js ships with a built-in http module. It works, but it makes you do everything manually — parse URLs, check HTTP methods, route requests, set headers, and serialize response bodies. Here's what a minimal server looks like in raw Node:

// raw-node-server.js
const http = require('http');

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/users') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ users: [] }));
  } else if (req.method === 'POST' && req.url === '/users') {
    let body = '';
    req.on('data', chunk => { body += chunk; });
    req.on('end', () => {
      const data = JSON.parse(body);
      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ created: data }));
    });
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
});

server.listen(3000, () => console.log('Server running on port 3000'));

This scales poorly. Adding five more routes turns into a deeply nested if-else tree. You're also manually streaming request bodies, setting headers on every response, and doing string matching on URLs.

Express solves all of this.


What Express.js Is

Express is a minimal web framework for Node.js. It wraps the native http module and adds:

  • A routing layer — match requests by method + path without if-else chains
  • Middleware pipeline — run functions before your route handler (auth, body parsing, logging)
  • Response helpersres.json(), res.status(), res.send() instead of manual writeHead + end
  • Request parsing — access req.params, req.query, req.body directly

It does not make architectural decisions for you. No ORM, no templating engine is forced on you. It's a routing + middleware layer, nothing more.


Solution

Step 1: Install Express

mkdir express-routing-demo && cd express-routing-demo
npm init -y
npm install express

This creates a project with Express as the only dependency. Node version 16+ is recommended.


Step 2: Create Your First Express Server

// server.js
const express = require('express');

const app = express();
const PORT = 3000;

app.use(express.json()); // Parse JSON request bodies automatically

app.listen(PORT, () => {
  console.log(`Server listening on http://localhost:${PORT}`);
});

express() returns an application object. app.use(express.json()) registers a built-in middleware that parses incoming JSON bodies and attaches the result to req.body. Without this, req.body is undefined for POST requests with JSON payloads.

Expected output when running node server.js:

Server listening on http://localhost:3000

Step 3: Handle GET Requests

Routes follow this pattern:

app.METHOD(PATH, HANDLER)
  • METHOD — HTTP verb in lowercase (get, post, put, delete)
  • PATH — URL string or pattern
  • HANDLER — function that receives (req, res)
// In-memory data store for this demo
const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob',   email: 'bob@example.com' },
];

// GET /users — return all users
app.get('/users', (req, res) => {
  res.status(200).json({ users });
});

// GET /users/:id — return a single user by ID
app.get('/users/:id', (req, res) => {
  const userId = parseInt(req.params.id, 10);
  const user = users.find(u => u.id === userId);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.status(200).json({ user });
});

Key concepts here:

  • req.params.id — Express extracts the :id segment from the URL automatically. No manual URL parsing.
  • res.status(200).json({ user }) — chains the status code with the JSON response helper. Express sets the Content-Type: application/json header automatically.
  • Returning early with return res.status(404).json(...) prevents the handler from continuing after sending the 404 response.

Test it:

curl http://localhost:3000/users
# {"users":[{"id":1,"name":"Alice","email":"alice@example.com"},{"id":2,"name":"Bob","email":"bob@example.com"}]}

curl http://localhost:3000/users/1
# {"user":{"id":1,"name":"Alice","email":"alice@example.com"}}

curl http://localhost:3000/users/99
# {"error":"User not found"}

Step 4: Handle POST Requests

// POST /users — create a new 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: users.length + 1,
    name,
    email,
  };

  users.push(newUser);

  res.status(201).json({ user: newUser });
});

req.body contains the parsed JSON payload because we registered express.json() middleware in Step 2. Without it, req.body would be undefined — a common mistake when starting with Express.

Test it:

curl -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"name": "Carol", "email": "carol@example.com"}'
# {"user":{"id":3,"name":"Carol","email":"carol@example.com"}}

curl -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"name": "Dave"}'
# {"error":"name and email are required"}

Step 5: Complete Working Server

Here is the full file combining everything above:

// server.js
const express = require('express');

const app = express();
const PORT = 3000;

app.use(express.json());

const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob',   email: 'bob@example.com' },
];

// GET /users
app.get('/users', (req, res) => {
  res.status(200).json({ users });
});

// GET /users/:id
app.get('/users/:id', (req, res) => {
  const userId = parseInt(req.params.id, 10);
  const user = users.find(u => u.id === userId);

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.status(200).json({ user });
});

// POST /users
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: users.length + 1, name, email };
  users.push(newUser);

  res.status(201).json({ user: newUser });
});

// 404 fallback — must be registered last
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

app.listen(PORT, () => {
  console.log(`Server listening on http://localhost:${PORT}`);
});

The 404 fallback is registered with app.use() at the end. Express processes routes in registration order — if no defined route matches, the request falls through to this handler.


Request → Route Handler → Response Flow

Incoming HTTP Request
        │
        ▼
┌──────────────────────┐
│   express.json()     │  ← Middleware: parses body, attaches to req.body
└──────────────────────┘
        │
        ▼
┌──────────────────────┐
│   Route Matching     │  ← app.get('/users') / app.post('/users/:id')
│   (method + path)    │
└──────────────────────┘
        │
        ▼
┌──────────────────────┐
│   Route Handler      │  ← Your function: reads req, builds response
│   (req, res) => {}   │
└──────────────────────┘
        │
        ▼
┌──────────────────────┐
│   res.json() /       │  ← Sends HTTP response with headers + body
│   res.send()         │
└──────────────────────┘
        │
        ▼
    HTTP Response

Results

Compare the same two-route implementation:

MetricRaw Node httpExpress
Lines for 2 routes~28 lines~18 lines
Manual body streamingRequiredHandled by middleware
URL param extractionManual string splitreq.params.id
JSON serializationJSON.stringify + headersres.json()
404 handlingManual else blockapp.use() fallback

As the number of routes grows, the gap widens significantly. A 10-route raw Node server typically requires 80-120 lines of routing logic alone. The same Express server stays under 60 lines.


Trade-offs

What Express adds on top of raw Node:

  • A small runtime overhead per request (~0.1–0.3ms on modern hardware for middleware processing). Negligible for most applications.
  • An additional dependency. If Express has a vulnerability, your app inherits it.
  • Middleware execution order matters — registering express.json() after your routes means request bodies won't be parsed in those routes. Bugs from ordering are subtle.

What Express does NOT give you:

  • Input validation — use zod or express-validator
  • Authentication — handle separately (Passport, JWT middleware)
  • Database abstraction — no ORM included
  • Request timeout handling — must be added explicitly

For very high-throughput internal services (100k+ req/s), frameworks like Fastify have a measurable performance advantage over Express due to a more efficient routing implementation and serialization layer. For most production APIs handling under 10k req/s, Express performance is not the bottleneck.


Conclusion

Express reduces Node.js server code to its essential parts: define a route, read the request, send a response. The routing layer (app.get, app.post), automatic parameter extraction (req.params, req.body), and response helpers (res.json, res.status) eliminate the boilerplate that makes raw Node HTTP servers hard to maintain.

The next step is organizing routes using express.Router() — which lets you split routes into separate files and mount them on path prefixes, keeping your codebase modular as it grows.


Further Reading