Skip to main content

Command Palette

Search for a command to run...

Creating Routes and Handling Requests with Express.js

Lets create a basic express app

Published
β€’9 min read
Creating Routes and Handling Requests with Express.js
S

Frontend Developer πŸ’» | Fueled by curiosity and Tea β˜• | Always learning and exploring new technologies.

Before Express.js existed, building an HTTP server in Node.js meant writing a lot of code just to get started.

What Express.js Actually Is

Express is a minimal web framework for Node.js. That word minimal is important it doesn't come with an ORM, a templating engine, a validation library, or an authentication system built in. It comes with routing, middleware support, and a cleaner interface around Node's http module.

This is intentional. Express is a foundation, not a full solution. The developer community built the ecosystem around it hundreds of middleware packages that plug in cleanly so you compose exactly what you need, rather than inheriting what a framework decided you should have.

Raw Node.js vs Express: The Problem It Solves

Let's see the gap side by side.

Handling two routes in raw Node.js:

const http = require('http');

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ message: 'Home' }));
  } else 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.toString(); });
    req.on('end', () => {
      const data = JSON.parse(body);
      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(data));
    });
  } else {
    res.writeHead(404);
    res.end('Not found');
  }
});

server.listen(3000);

This works. But notice what you're dealing with:

  • Manual if/else routing on every request

  • Checking method and URL manually every time

  • Manually reading the request body stream in chunks

  • Manually setting Content-Type headers on every response

  • No URL parameters (try matching /users/42 β€” you'd need a regex or a URL parser)

The same routes in Express:

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

app.get('/', (req, res) => {
  res.json({ message: 'Home' });
});

app.get('/users', (req, res) => {
  res.json({ users: [] });
});

app.post('/users', (req, res) => {
  res.status(201).json(req.body);
});

app.listen(3000);

Express removes all the boilerplate. The routing is declarative. Body parsing is handled by middleware. res.json() sets Content-Type automatically. The intent of every route is immediately obvious.

Installing Express

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

Create index.js and you're ready.

Your First Express Server

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

app.use(express.json());

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

Three things happening here:

require('express') β€” imports the Express module. Express exports a function; calling it creates an application instance.

app.use(express.json()) β€” registers middleware. This specific middleware reads the incoming request body and parses it as JSON, making it available as req.body. Without this line, req.body is undefined for POST requests. It only parses requests with Content-Type: application/json β€” it doesn't touch form submissions or plain text.

app.listen(3000, callback) β€” binds the server to port 3000. The callback fires once when the server is ready. This is exactly equivalent to creating an http.Server and calling .listen() on it.

Express does that for you internally.

How Routing Works in Express

A route is the combination of an HTTP method and a URL path. When a request arrives, Express walks through your registered routes in the order you defined them and calls the first one that matches both the method and the path.

The basic signature is:

js

app.METHOD(PATH, HANDLER)

Where METHOD is get, post, put, delete, patch, or others. PATH is a string (or a regex, or an array). HANDLER is a function that receives req and res.

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

If a GET request arrives for /hello, this handler runs. If the method is POST, it won't match. If the path is /hello/world, it won't match either (unless you use wildcards).

What most tutorials don't explain: the order of route definitions matters. Express matches routes top to bottom, first match wins. If you define a broad route before a specific one, the broad one catches the request and the specific one never runs.

// Broad route defined first β€” always wins
app.get('/users/*', (req, res) => {
  res.send('wildcard'); // this matches /users/admin too
});

// This never runs β€” the route above caught it first
app.get('/users/admin', (req, res) => {
  res.send('admin panel');
});

Always define specific routes before general ones.

Handling GET Requests

GET is the most common HTTP method fetch data, list resources, retrieve a single item.

// Simple static route
app.get('/', (req, res) => {
  res.json({ status: 'ok', version: '1.0' });
});

// List resource
app.get('/products', (req, res) => {
  const products = [
    { id: 1, name: 'Keyboard', price: 79 },
    { id: 2, name: 'Mouse',    price: 39 },
  ];
  res.status(200).json(products);
});

// Single resource with URL parameter
app.get('/products/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const product = products.find(p => p.id === id);

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

  res.status(200).json(product);
});

:id in the path is a named URL parameter. Express captures whatever is in that position in the URL and puts it in req.params.id. More on parameters in the next article for now, know they exist and look like :name.

Handling POST Requests

POST is used to create new resources. Data comes in the request body, not the URL.

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

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

  if (typeof price !== 'number' || price < 0) {
    return res.status(400).json({ error: 'price must be a non-negative number' });
  }

  // Create
  const newProduct = {
    id: Date.now(), // simple ID for demo
    name,
    price,
  };

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

req.body works because express.json() middleware ran first and parsed the request body. If the client sends Content-Type: application/json and valid JSON in the body, req.body will be a JavaScript object.

The return before each error response is critical. Without it, Express attempts to send a second response after the first res.json() call, which throws a "Cannot set headers after they are sent" error. The return exits the handler function immediately.

What happens when the client sends invalid JSON? If the body is malformed, express.json() will respond with a 400 error automatically before your handler even runs. You don't need to handle JSON parse errors yourself β€” the middleware does it. But if you want to customize that error response (to match your API's error format), you can add an error-handling middleware:

app.use((err, req, res, next) => {
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({ error: 'Invalid JSON in request body' });
  }
  next(err);
});

The four-parameter signature (err, req, res, next) is Express's convention for error-handling middleware. It only runs when an error is passed or thrown.

Sending Responses

Express's res object extends Node's built-in response, adding helper methods that handle headers automatically.

res.json(data) β€” sends a JSON response. Sets Content-Type: application/json and status 200 automatically. Calls JSON.stringify internally.

res.json({ id: 1, name: 'Alice' });

res.status(code) β€” sets the HTTP status code. It returns res so you can chain it:

res.status(201).json({ id: 1, name: 'Alice' }); // 201 Created
res.status(404).json({ error: 'Not found' });    // 404 Not Found

res.send(body) β€” sends a response. If you pass a string, Content-Type is text/html. If you pass an object, it behaves like res.json(). Prefer res.json() for APIs β€” it's explicit about the content type.

res.status(204).send() β€” sends an empty response. Required for DELETE endpoints where there's no body to return.

res.set(header, value) β€” sets a custom response header:

res.set('X-Request-Id', 'abc123').json({ data: '...' });

What most developers don't know about res.json(): it runs JSON.stringify with the json spaces app setting. You can configure pretty-printing for development:

app.set('json spaces', 2); // pretty-print JSON in development

This makes responses human-readable in development without changing your code. Set it conditionally:

if (process.env.NODE_ENV !== 'production') {
  app.set('json spaces', 2);
}

A Complete Working Server

Here's everything put together β€” a small but complete server covering multiple routes and both methods:

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

app.use(express.json());

// In-memory store
let books = [
  { id: 1, title: 'The Pragmatic Programmer', author: 'Hunt & Thomas' },
  { id: 2, title: 'Clean Code',               author: 'Robert Martin'  },
];
let nextId = 3;

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

// GET one book
app.get('/books/:id', (req, res) => {
  const book = books.find(b => b.id === parseInt(req.params.id));
  if (!book) return res.status(404).json({ error: 'Book not found' });
  res.status(200).json(book);
});

// POST create a book
app.post('/books', (req, res) => {
  const { title, author } = req.body;

  if (!title || !author) {
    return res.status(400).json({ error: 'title and author are required' });
  }

  const book = { id: nextId++, title, author };
  books.push(book);
  res.status(201).json(book);
});

// 404 fallback β€” must be last
app.use((req, res) => {
  res.status(404).json({ error: `Cannot \({req.method} \){req.url}` });
});

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

Express Router: Splitting Routes Across Files

As an application grows, keeping every route in index.js becomes unmanageable. Express's Router is a mini-app β€” it has its own .get(), .post(), .use(), etc. β€” that you can mount on a path.

// routes/books.js
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.json(books);
});

router.post('/', (req, res) => {
  // create book
});

router.get('/:id', (req, res) => {
  // get one book
});

module.exports = router;
// index.js
const booksRouter = require('./routes/books');
app.use('/books', booksRouter);

Routers are also just middleware. You can stack multiple routers, nest them, add middleware that only applies to a specific router, and reuse them across different mount points. The same booksRouter mounted at /api/v1/books and /api/v2/books would serve both paths from the same code.

Understand what Express does under the hood (it's mostly Node's http module with a layer on top) and you'll never be confused by its behavior. Understand how route matching works (top-down, first match wins) and you'll never write a route that silently never runs.

I’m currently deep-diving into the JavaScript, building projects and exploring the internals of the web. If you're on a similar journey or just love talking about JavaScript, let’s stay in touch!

Keep coding and keep building.