Express Complete Tutorial Part 3: Route Requests

Every Express app has to decide which code handles each URL a visitor requests. A route pairs an HTTP method, such as GET or POST, with a URL path and runs its handler when both match.

This part of the series shows you how to read input from the URL and from a JSON body.

What an Express route matches

An Express route pairs an HTTP method with a URL path and runs its handler when both match.

The Express 5 routing reference documents this method-and-path match and how handlers read named path segments. Part 1 of the series sets up the Express app that receives those routes.

Request partExampleRead it fromUse it for
Path segment/profiles/42req.params.idIdentify the requested profile
Query string?view=compactreq.query.viewChoose an optional view
JSON body{“text”:”route input”}req.body.textRead submitted data

A query string supplies optional handler input without changing the path Express matches.

What you need before writing a route

Express 5 requires Node.js 18 or newer. The example uses CommonJS and the built-in JSON parser, so you do not need a separate body-parsing package.

  • Use a project directory with Node.js and npm available.
  • Start with an empty package manifest so the install command can add Express.
  • Keep the sample files together because the test script imports the app file.

From the project directory, initialize the package manifest and install Express without a version suffix.

npm init -y
npm install express

Add handlers for path, query, and JSON input

Because Express runs middleware in order, register the JSON parser before the routes that read req.body.

Step 1: Define the request handlers

The GET endpoint reads a required profile ID from the path and an optional view from the query string, while the POST endpoint validates submitted JSON. Blank or missing text gets status 400, and accepted text comes back trimmed.

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

app.use(express.json());

app.get('/profiles/:id', (req, res) => {
  const view = req.query.view ?? 'summary';
  res.json({ id: req.params.id, view });
});

app.post('/preview', (req, res) => {
  const text = req.body?.text;

  if (typeof text !== 'string' || !text.trim()) {
    return res.status(400).json({ error: 'text must be a non-empty string' });
  }

  return res.json({ preview: text.trim() });
});

if (require.main === module) {
  const port = process.env.PORT || 3100;
  app.listen(port, () => {
    console.log(`Express listening on http://localhost:${port}`);
  });
}

module.exports = app;

The request object reference describes req.params for path values and req.query for the optional view. The default keeps the response useful when the query value is absent.

Express 5 can leave req.body undefined when a request has no body. Optional chaining lets this handler return a 400 response instead of throwing while it reads the submitted text.

The POST handler returns a preview and does not authenticate a user or save data.

If you need a rendered page, use the EJS setup from Part 2 of the series. Part 4 covers session handling.

Step 2: Exercise the routes

This smoke check starts the exported app on a temporary port, sends HTTP requests through the server, and asserts each status and JSON response. It covers a path parameter, a query value, a valid JSON body, and a POST with no body.

const assert = require('node:assert/strict');
const app = require('./app');

async function run() {
  const server = app.listen(0);
  await new Promise((resolve) => server.once('listening', resolve));
  const baseUrl = `http://127.0.0.1:${server.address().port}`;

  try {
    const profileResponse = await fetch(`${baseUrl}/profiles/42?view=compact`);
    const profile = await profileResponse.json();
    assert.equal(profileResponse.status, 200);
    assert.deepEqual(profile, { id: '42', view: 'compact' });
    console.log(`GET /profiles/42?view=compact -> ${profileResponse.status} ${JSON.stringify(profile)}`);

    const previewResponse = await fetch(`${baseUrl}/preview`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: '  route input  ' })
    });
    const preview = await previewResponse.json();
    assert.equal(previewResponse.status, 200);
    assert.deepEqual(preview, { preview: 'route input' });
    console.log(`POST /preview with JSON -> ${previewResponse.status} ${JSON.stringify(preview)}`);

    const missingBodyResponse = await fetch(`${baseUrl}/preview`, { method: 'POST' });
    const missingBody = await missingBodyResponse.json();
    assert.equal(missingBodyResponse.status, 400);
    assert.deepEqual(missingBody, { error: 'text must be a non-empty string' });
    console.log(`POST /preview without body -> ${missingBodyResponse.status} ${JSON.stringify(missingBody)}`);
  } finally {
    await new Promise((resolve, reject) => {
      server.close((error) => error ? reject(error) : resolve());
    });
  }
}

run().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Save smoke.js beside app.js and run it with Node after copying the code above.

The test opens the app on an available port and checks the responses. A finally block closes the server even if an assertion fails.

node smoke.js
Express route responses for a profile, valid JSON preview, and missing request body
The GET handler returns the profile path value and view query. The POST handler accepts JSON and returns 400 when the body is missing.

When a route or request does not match

Match the method and path first, then validate optional query and body values inside the handler.

RequestWhat Express matchesResult in this example
GET /profiles/42GET route matches, and view is absentReturns the default summary view
GET /previewNo GET route matches this pathExpress returns its default 404 response
POST /preview without a bodyPOST route matches with req.body possibly undefinedHandler returns 400 instead of reading a missing field
POST /preview with text set to spacesPOST route matches, but blank text fails validationHandler returns the same 400 error

A Stack Overflow question described the symptom as “But the handler isn’t getting called! Any suggestions?” after its route included query parameters.

Keep those values out of the route path and read them from req.query.

Another Stack Overflow post described the mismatch as “this code is not working when I try to get year and month as typing in url”. Its URL used path segments while the handler read query values, so the route path and request property need to agree.

An Express 5 report says “req.body is undefined when not explicitly including a request body.” The handler guards that absence and returns 400 instead of trying to read a missing field, as the issue describes.

Let the URL shape name the input

After changing a route, send the same request again and compare its status and JSON response.

node smoke.js

Express routing questions

Route parameters, query values, and parsed JSON arrive in different request fields. Use express.Router() when related handlers need a shared mount path.

Can a query string be part of an Express route path?

No. Express matches the request method and path, then the handler reads optional query values from req.query.

Why can req.body be undefined in Express 5?

A request may have no body, or the JSON parser may not handle its content type. Put express.json() before the route and validate the body before reading fields.

When should routes move into express.Router()?

Use express.Router() when a group of route handlers belongs together and can share a mount path. A single small example can keep its handlers on the app instance.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 337