Routing by Hand

Reviewed & published by Brayan K

Routing is the logic that decides which response to send based on the request's URL path and HTTP method — and before frameworks like Express, you write it by hand with conditionals on req.url and req.method.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

By the end of this lesson you'll route requests with plain if and switch statements, parse paths and query strings safely with the URL class, and build a small method-aware route table that returns JSON with proper status codes like 404 and 405.

What You'll Learn in This Lesson

1️⃣ Routing with if on req.url and req.method

A Node server's request handler runs for every incoming request, no matter the URL. Routing is simply the code inside that handler that inspects the request and decides what to send back. The two properties you check are req.url (the path, like /about) and req.method (the verb, like GET or POST).

The pattern is: check a condition, respond, and return so no other branch runs. The crucial last branch is the 404 fallback — without it, unmatched requests hang forever because you never called res.end(). Read every line below, then run it.

// Routing means: "look at the request, decide what to send back."
// Before frameworks like Express, you do it by hand with plain ifs.
const http = require('http');

const server = http.createServer((req, res) => {
  // req.url is the path the browser asked for (e.g. "/about").
  // req.method is the HTTP verb (e.g. "GET", "POST").

  // Route 1: the home page — GET /
  if (req.method === 'GET' && req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Welcome home');              // -> Welcome home
    return;                               // stop: we already responded
  }

  // Route 2: the about page — GET /about
  if (req.method === 'GET' && req.url === '/about') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('About this site');           // -> About this site
    return;
  }

  // Fallback: nothing matched, so this is a 404 Not Found.
  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('Not Found');                   // -> Not Found
});

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

2️⃣ Parsing the URL and Query Strings

There's a trap in Section 1: req.url includes the query string. A request to /greet?name=Sam has a req.url of "/greet?name=Sam" — so a plain req.url === '/greet' check silently fails. The fix is the built-in URL class.

Build a URL with new URL(req.url, 'http://localhost') (the second argument is a required base, since req.url is relative). Then url.pathname gives you the path with no query, and url.searchParams.get('name') reads query values safely.

const http = require('http');

const server = http.createServer((req, res) => {
  // req.url includes BOTH the path AND the query string, e.g.
  //   "/greet?name=Sam"
  // Comparing that whole string is fragile. Parse it instead.
  // The second argument is a base — required because req.url is relative.
  const url = new URL(req.url, 'http://localhost');

  // url.pathname is just the path with NO query string: "/greet"
  if (url.pathname === '/greet') {
    // url.searchParams reads ?key=value pairs safely.
    // If "name" is missing, default to "stranger".
    const name = url.searchParams.get('name') || 'stranger';
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end(`Hello, ${name}!`);          // -> Hello, Sam!
    return;
  }

  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('Not Found');
});

server.listen(3000);

3️⃣ Method-Aware Routing and a Route Table

Real APIs serve the same path differently depending on the method: GET /users lists users, while POST /users creates one. So you branch first on the path, then on req.method inside it. Status codes matter here: send 200 for a successful read, 201 Created after a POST, 404 when the path doesn't exist, and 405 Method Not Allowed when the path exists but the verb is wrong.

To return JSON, set 'Content-Type': 'application/json' and pass your object through JSON.stringify(...). A small sendJson helper keeps every branch tidy and consistent.

const http = require('http');

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const path = url.pathname;

  // Helper: always send JSON with the right status code.
  const sendJson = (status, data) => {
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  // The SAME path can behave differently per method.
  if (path === '/users') {
    if (req.method === 'GET') {
      return sendJson(200, { users: ['Ana', 'Bo'] });
    }
    if (req.method === 'POST') {
      return sendJson(201, { created: true });   // 201 = Created
    }
    // Path exists, but this verb isn't allowed here.
    res.writeHead(405, { 'Allow': 'GET, POST' }); // 405 = Method Not Allowed
    return res.end('Method Not Allowed');
  }

  // No path matched at all -> 404 Not Found.
  return sendJson(404, { error: 'Not Found' });
});

server.listen(3000);

Your turn. The handler below works once you fill in the blanks marked ___. Follow the 👉 hints, run it with node server.js, and compare with the expected output.

const http = require('http');

const server = http.createServer((req, res) => {
  // 🎯 YOUR TURN — replace each ___ then run it.

  // 1) Handle GET /hello by sending the text "Hi there!"
  if (req.method === '___' && req.url === '___') {  // 👉 'GET' and '/hello'
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('___');                                 // 👉 'Hi there!'
    return;                                         // 👉 stop after responding
  }

  // 2) Everything else is a 404 — fill in the status code.
  res.writeHead(___, { 'Content-Type': 'text/plain' }); // 👉 404
  res.end('Not Found');
});

server.listen(3000);

// ✅ Expected output:
//    $ curl localhost:3000/hello   ->  Hi there!
//    $ curl localhost:3000/other   ->  Not Found

Mini-Challenge: A Tiny Todo API

No blanks this time — just a brief and an outline. Build the route table yourself, run it, and check your output against the example in the comments. This is exactly the kind of hands-on routing that makes Express click later.

const http = require('http');

const server = http.createServer((req, res) => {
  // 🎯 MINI-CHALLENGE: a tiny "todo" API
  // 1. Parse the URL with new URL(req.url, 'http://localhost').
  // 2. For GET /todos  -> respond 200 with JSON: { items: ["learn routing"] }
  // 3. For POST /todos -> respond 201 with JSON: { added: true }
  // 4. For any other method on /todos -> respond 405 (Method Not Allowed).
  // 5. For any other path -> respond 404 with JSON: { error: "Not Found" }.
  // (Remember Content-Type: 'application/json' and JSON.stringify.)
  //
  // ✅ Example output:
  //    GET  /todos  ->  {"items":["learn routing"]}
  //    POST /todos  ->  {"added":true}
  //    GET  /nope   ->  {"error":"Not Found"}

  // your code here
});

server.listen(3000);

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Manual Routing

ConceptNode Syntax
Request path (+ query)req.url
HTTP method/verbreq.method
Parse the URLnew URL(req.url, base)
Path without queryurl.pathname
Read a query paramurl.searchParams.get(k)
Send JSONJSON.stringify({...})
Not foundres.writeHead(404)

🎉 Lesson Complete!

Practice quiz

What two request properties do you inspect to route by hand?

  • req.body and req.headers
  • req.url and req.method
  • req.host and req.port
  • req.query and req.cookies

Answer: req.url and req.method. req.url gives the path and req.method gives the HTTP verb; together they decide the response.

Why must a manual router end with a 404 fallback?

  • To log requests
  • To set headers
  • Otherwise unmatched requests hang because res.end is never called
  • To enable HTTPS

Answer: Otherwise unmatched requests hang because res.end is never called. Without a catch-all that ends the response, an unmatched request never finishes and hangs.

Why is comparing req.url directly to '/greet' fragile?

  • req.url is always uppercase
  • req.url includes the query string, e.g. '/greet?name=Sam'
  • req.url is a number
  • req.url is undefined for GET

Answer: req.url includes the query string, e.g. '/greet?name=Sam'. req.url contains path plus query string, so an exact match fails when a query is present.

How do you parse the path out of req.url safely?

  • req.url.split('?')[0] only
  • JSON.parse(req.url)
  • req.path
  • new URL(req.url, 'http://localhost') then url.pathname

Answer: new URL(req.url, 'http://localhost') then url.pathname. Building a URL with a base lets you read url.pathname without the query string.

How do you read a query parameter like ?name=Sam?

  • url.searchParams.get('name')
  • req.params.name
  • url.query.name
  • req.body.name

Answer: url.searchParams.get('name'). url.searchParams.get('name') reads query values from the parsed URL.

What status code means the path exists but the HTTP verb is not allowed?

  • 404 Not Found
  • 405 Method Not Allowed
  • 200 OK
  • 500 Server Error

Answer: 405 Method Not Allowed. 405 Method Not Allowed signals the path matched but the method is unsupported; pair it with an Allow header.

When returning JSON manually, what must you do to the object?

  • Pass it directly to res.end
  • Set status 201 only
  • Pass it through JSON.stringify and set Content-Type application/json
  • Encrypt it

Answer: Pass it through JSON.stringify and set Content-Type application/json. res.end sends strings, so stringify the object and set the JSON Content-Type.

Why might a route for '/about' fail to match '/about/'?

  • Trailing slashes are reserved
  • /about/ is a query string
  • String comparison is exact, so the two differ
  • Node lowercases paths

Answer: String comparison is exact, so the two differ. '/about' and '/about/' are different strings, so the trailing slash slips past an exact check.

What does Express's app.get('/about', ...) do under the hood?

  • The same method-plus-path check you write by hand
  • Opens a database connection
  • Caches the response
  • Encrypts the route

Answer: The same method-plus-path check you write by hand. Frameworks are sugar over the same req.method and req.url checks plus a 404 fallback.

Why should you return after calling res.end() in a branch?

  • To restart the server
  • To free memory
  • To prevent a later branch writing to an already-finished response
  • To log the request

Answer: To prevent a later branch writing to an already-finished response. Returning stops later code from trying to respond again on a finished response.

Continue this course

Frequently asked questions

Why would I route by hand instead of just using Express?

You usually wouldn't for a big app — but writing routing by hand once teaches you exactly what frameworks do for you. Express's app.get('/about', ...) is, under the hood, a check on req.method and req.url plus a 404 fallback, the same logic you write manually here. Understanding the raw version makes Express feel obvious instead of magical, and it's handy for tiny scripts, serverless functions, or environments where you can't add dependencies.

Should I compare against req.url or parse it first?

Parse it first for anything beyond the simplest cases. req.url contains the whole thing including the query string, so '/about' and '/about?ref=email' are different strings even though they're the same page. Use const url = new URL(req.url, 'http://localhost') and compare url.pathname, which strips the query off. Then read query values safely with url.searchParams.get('key').

What status code should I send when the method is wrong?

Use 405 Method Not Allowed when the path exists but the HTTP verb isn't supported there — for example a DELETE on a route that only handles GET and POST. Pair it with an Allow header listing the verbs you do accept. Reserve 404 Not Found for when the path itself doesn't match any route at all. Sending the right code helps clients and tools behave correctly.

Why does my route for '/about' not match '/about/'?

Because string comparison is exact: '/about' and '/about/' are different strings, so a trailing slash slips past your check and falls through to the 404. Decide on a convention and enforce it — for example normalise the path by trimming a trailing slash before comparing, or list both forms. Frameworks handle this with configurable trailing-slash and case-sensitivity options, but by hand it's on you.