Skip to main content

Express.js: Building Web Servers Without the Boilerplate

Updated 11 min readMarkdown

Node.js gave JavaScript a runtime outside the browser. That was significant — suddenly you could write server-side code in the same language you used on the frontend. But Node.js on its own is deliberately low-level. It hands you the tools to build a web server, but it does not hand you a web server. Every route, every header, every parsed request body — you write it yourself.

For a toy project, that is fine. For anything real, it becomes a maintenance problem quickly. Express.js exists to solve that problem.


What Express.js Is

Express is a minimal web framework for Node.js. It sits on top of Node's built-in http module and adds a thin, clean layer of structure: routing, middleware, request parsing, and response helpers.

The word "minimal" is doing real work in that description. Express does not make decisions for you about databases, templating engines, authentication, or project layout. It handles the plumbing of HTTP — mapping incoming requests to handlers and sending responses — and leaves everything else up to you. That philosophy is why it became the dominant Node.js framework and why most other Node frameworks are either built on top of it or explicitly model themselves after it.

Express is not the newest option in the ecosystem. Fastify is faster. NestJS gives you more structure. Hono targets edge runtimes. But Express remains widely used because it is stable, well-documented, and has a solved-problem feel to it. Almost any HTTP-related thing you want to do has an Express example somewhere.


The Problem Express Solves

To understand what Express adds, it helps to see what you are working with when you do not have it.

Here is a basic HTTP server in raw Node.js that returns "Hello" for one route and a 404 for everything else:

const http = require('http');

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Hello');
  } else if (req.method === 'GET' && req.url === '/about') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('About page');
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Not found');
  }
});

server.listen(3000);

Now add a POST route that reads a JSON body:

const http = require('http');

const server = http.createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/users') {
    let body = '';

    req.on('data', (chunk) => {
      body += chunk.toString();
    });

    req.on('end', () => {
      const data = JSON.parse(body);
      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ created: data }));
    });
  }
});

server.listen(3000);

You are managing event streams, manually concatenating chunks, parsing JSON yourself, and setting every header by hand. Add a few more routes and some error handling and this becomes genuinely difficult to read.

Here is the same two routes in Express:

const express = require('express');
const app = express();

app.use(express.json());

app.get('/', (req, res) => {
  res.send('Hello');
});

app.get('/about', (req, res) => {
  res.send('About page');
});

app.post('/users', (req, res) => {
  const data = req.body; // already parsed
  res.status(201).json({ created: data });
});

app.listen(3000);

Same result. A fraction of the complexity. Every route is a declarative statement about what URL and method it handles. The request body arrives already parsed. Responses are sent with helper methods that set the right headers automatically.


Creating Your First Express Server

Start by initializing a project and installing Express:

mkdir my-server
cd my-server
npm init -y
npm install express

Create index.js:

const express = require('express');

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

app.get('/', (req, res) => {
  res.send('Server is running');
});

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

Run it with node index.js and visit http://localhost:3000. That is a working web server.

The pattern here is consistent across every Express application:

  1. Import Express and call it to create an app instance

  2. Register route handlers on the app

  3. Call app.listen() with a port

Everything else — middleware, databases, authentication — gets wired in between steps 2 and 3.


How Requests Flow Through Express

Before looking at specific request types, it is worth understanding the mental model.

Incoming HTTP Request
        │
        ▼
┌───────────────────────────────────────────────────────┐
│                    Express App                        │
│                                                       │
│  ┌─────────────────────────────────────────────────┐  │
│  │              Middleware Stack                   │  │
│  │                                                 │  │
│  │   express.json()  →  logger  →  auth check ...  │  │
│  └──────────────────────────┬──────────────────────┘  │
│                             │                         │
│                             ▼                         │
│  ┌──────────────────────────────────────────────────┐ │
│  │               Route Matching                     │ │
│  │                                                  │ │
│  │   Does method + path match a registered route?  │ │
│  └──────────────────────┬───────────────────────────┘ │
│                         │                             │
│          ┌──────────────┼──────────────┐              │
│          ▼              ▼              ▼              │
│   GET /users    POST /users    GET /users/:id         │
│   (handler)     (handler)      (handler)              │
│          │              │              │              │
└──────────┼──────────────┼──────────────┼──────────────┘
           │              │              │
           ▼              ▼              ▼
     res.json()      res.status(201)   res.send()
       200 OK        .json()  201       200 OK

Every incoming request passes through the middleware stack first, then Express checks which route matches the method and path, runs the corresponding handler, and the handler sends a response. If no route matches, Express sends a 404 by default.


Handling GET Requests

GET requests are used to retrieve data. They have no body — all parameters come from the URL itself, either as path segments or query strings.

const express = require('express');
const app = express();

// Static route
app.get('/users', (req, res) => {
  res.json([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
  ]);
});

// Route parameter — :id is a named placeholder
app.get('/users/:id', (req, res) => {
  const userId = req.params.id;  // "123" from /users/123
  res.json({ id: userId, name: 'Alice' });
});

// Query string — /search?q=node&limit=10
app.get('/search', (req, res) => {
  const { q, limit } = req.query;  // { q: 'node', limit: '10' }
  res.json({ query: q, limit: Number(limit) });
});

app.listen(3000);

req.params gives you path parameters (:id becomes req.params.id). req.query gives you query string values, already parsed into an object. Both arrive as strings, so convert types explicitly when needed.


Handling POST Requests

POST requests are used to send data to the server — creating a resource, submitting a form, triggering an action. The data lives in the request body.

To access the body, you need to tell Express how to parse it. express.json() is a built-in middleware that parses requests with a Content-Type: application/json header and puts the result on req.body.

const express = require('express');
const app = express();

// Must be registered before routes that need req.body
app.use(express.json());

app.post('/users', (req, res) => {
  const { name, email } = req.body;

  // Validate
  if (!name || !email) {
    return res.status(400).json({ error: 'name and email are required' });
  }

  // In a real app you would save to a database here
  const newUser = { id: Date.now(), name, email };

  res.status(201).json(newUser);
});

app.listen(3000);

app.use(express.json()) is called before the route definitions. Order matters in Express — middleware runs in the order it is registered, and if the body parser has not run by the time your route handler executes, req.body will be undefined.

For HTML form submissions (where the content type is application/x-www-form-urlencoded rather than JSON), use express.urlencoded({ extended: true }) instead of or alongside express.json().


Express Routing Structure

As your application grows, keeping all routes in a single file becomes unwieldy. Express has a Router class that lets you create modular route groups.

┌─────────────────────────────────────────────────────┐
│                      app.js                         │
│                                                     │
│  app.use('/users',   usersRouter)  ──────────────┐  │
│  app.use('/posts',   postsRouter)  ──────────┐   │  │
│  app.use('/auth',    authRouter)   ──────┐   │   │  │
│                                          │   │   │  │
└──────────────────────────────────────────┼───┼───┼──┘
                                           │   │   │
          ┌────────────────────────────────┘   │   │
          │   authRouter                       │   │
          │   POST /login                      │   │
          │   POST /register                   │   │
          │   POST /logout                     │   │
                                               │   │
               ┌───────────────────────────────┘   │
               │   postsRouter                     │
               │   GET  /                          │
               │   GET  /:id                       │
               │   POST /                          │
               │   DELETE /:id                     │
                                                   │
                    ┌──────────────────────────────┘
                    │   usersRouter
                    │   GET  /
                    │   GET  /:id
                    │   POST /
                    │   PUT  /:id

Here is what this looks like in code.

routes/users.js:

const { Router } = require('express');
const router = Router();

router.get('/', (req, res) => {
  res.json({ message: 'Get all users' });
});

router.get('/:id', (req, res) => {
  res.json({ message: `Get user ${req.params.id}` });
});

router.post('/', (req, res) => {
  res.status(201).json({ message: 'User created', data: req.body });
});

module.exports = router;

app.js:

const express = require('express');
const usersRouter = require('./routes/users');

const app = express();
app.use(express.json());

app.use('/users', usersRouter);

app.listen(3000);

Requests to /users and /users/123 are handled by the router. Inside the router file, paths are written relative to the mount point — router.get('/:id') handles /users/:id, not just /:id. This makes router files self-contained and easy to test in isolation.


Sending Responses

Express extends the res object with methods that handle content type negotiation and serialization automatically.

// Plain text — Content-Type: text/html by default
res.send('Hello');

// JSON — Content-Type: application/json, object is serialized automatically
res.json({ name: 'Alice', role: 'admin' });

// Set status code, then send
res.status(404).json({ error: 'User not found' });
res.status(201).json({ id: 42, created: true });

// Send with no body (useful for DELETE, 204 No Content)
res.status(204).send();

// Redirect
res.redirect('/login');
res.redirect(301, '/new-url');

res.json() is equivalent to calling JSON.stringify(), setting the Content-Type header to application/json, and calling res.send(). The convenience matters because it removes a whole class of bugs where you forget to set the header or serialize incorrectly.

One important rule: you can only send one response per request. Calling res.json() after res.send() in the same handler will throw an error. If you have conditional logic, use early returns:

app.get('/users/:id', (req, res) => {
  const user = findUser(req.params.id);

  if (!user) {
    return res.status(404).json({ error: 'Not found' }); // return ends execution
  }

  res.json(user); // only reached if user was found
});

Middleware: The Glue

Middleware functions are at the core of how Express works. A middleware is any function with the signature (req, res, next) — it receives the request, can modify it, and either sends a response or calls next() to pass control to the next function in the chain.

// A simple request logger
function logger(req, res, next) {
  console.log(`\({req.method} \){req.url}`);
  next(); // must call next, or the request hangs
}

app.use(logger); // applies to all routes

Every call to app.use() and every route handler is middleware. express.json() is middleware. Authentication checks are middleware. Error handlers are middleware. They all share the same (req, res, next) contract and compose together to form the full request handling pipeline.


A Complete Working Example

Putting it all together — a small REST API for a list of users:

const express = require('express');
const app = express();

app.use(express.json());

// In-memory store (use a database in production)
let users = [
  { id: 1, name: 'Alice', email: '[email protected]' },
  { id: 2, name: 'Bob',   email: '[email protected]'   },
];

// 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 === Number(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

// POST create 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(newUser);
});

// DELETE user
app.delete('/users/:id', (req, res) => {
  const index = users.findIndex((u) => u.id === Number(req.params.id));
  if (index === -1) return res.status(404).json({ error: 'User not found' });
  users.splice(index, 1);
  res.status(204).send();
});

app.listen(3000, () => console.log('API running on http://localhost:3000'));

This is a real API. It handles four endpoints, validates input, returns appropriate status codes, and follows standard REST conventions. It is about 40 lines. The equivalent in raw Node.js would be three to four times longer and significantly harder to read.


Summary

Express takes Node's low-level http module and adds the structure that almost every web server needs: route definitions, middleware composition, request parsing, and response helpers. It does not add much more than that, which is both its limitation and its strength.

The core ideas are:

  • An Express app is a configured pipeline of middleware and routes

  • Routes match on method + path and run a handler function

  • req.params holds URL path parameters, req.query holds query strings, req.body holds the parsed request body (after a body parser middleware)

  • res.json(), res.send(), and res.status() cover almost all response scenarios

  • Router lets you split routes into modular files and mount them at path prefixes

From here, the natural next steps are connecting a database, adding authentication middleware, and handling errors globally — all of which follow the same middleware pattern introduced here.


Code examples use CommonJS (require) for compatibility. If your project uses "type": "module" in package.json, replace require with import and module.exports with export default.

No comments yet. Be the first to comment.

More from this blog