Lesson Overview

Routing is the process of deciding how your application should respond to different types of requests. When a request comes in, your server needs to decide what function(s) should process that request. For example:, an app would route a request to a home page differently than it would route a request to submit login credentials. Your code can evaluate the type and URL of the request and send an appropriate response based on that information.

We would typically not route requests using Node.js alone: it's not great when you have many different types of files or want to handle POST requests. However, we will explore a basic example so you can better understand how it works (and why we need frameworks like Express.js)!

Pre-Requisites

Before doing this lesson, make sure you've tried all the examples in the First Node.js Apps lesson.

A Simple Route Handler

A lot of programming Node.js programs is route handling. This involves taking each request and determining which function(s) should execute for that request. We need to learn a bit more before we can do an actual route handler, but we can simulate a basic one just so that we can understand route handling in general.

Start a new project (mine is called /route_handler_simple) and add your app.js file. You can copy and paste the following code into it:

"use strict";
    // example of basic routing, not a "real life" example,
    // just to understand how it works
    
    // map of responses for various URL patterns
    // - key is the pattern in the URL
    // - value is the response we want to give
    const routeResponseMap = {
        "/routes/info": "<h1>Info Page</h1>",
        "/routes/contact": "<h1>Contact Us</h1>",
        "/routes/about": "<h1>Learn More About Us.</h1>",
        "/routes/hello": "<h1>Say hello by emailing us here</h1>",
        "/routes/error": "<h1>Someday I'll Make an Error Page</h1>"
    };
    
    const port = 3000,
        http = require("http");
    
    http.createServer((req, res) => {
        console.log("Received an incoming request!");
        console.log(req.url);
    
        res.writeHead(200, {
            "Content-Type": "text/html"
        });
    
        // see if the url matches any in the mapping table
        // (if this key exists in the map = a falsy expression)
        if (routeResponseMap[req.url]) {
            // found it: send the value as the response body
            // included the request URL for debugging
            res.write(routeResponseMap[req.url]);
            res.end(`<p>Request: ${req.url}</p>`);
        } else {
            // not found, just generic welcome response 
            // with the request URL for debugging
            res.end(`<h1>Welcome Page</h1><p>Request: ${req.url}</p>`);
        }
    }).listen(port, () => { 
        console.log(`Server running on port ${port}`);
    });

The first block of code in the file defines a map of routes as key-value pairs. The key is the request URL and the value is the heading we will send in the response body. For any request, we'll look up the request URL in the routeResponseMap and add the corresponding heading to the response body.

const routeResponseMap = {
  "/routes/info": "<h1>Info Page</h1>",
  "/routes/contact": "<h1>Contact Us</h1>",
  "/routes/about": "<h1>Learn More About Us.</h1>",
  "/routes/hello": "<h1>Say hello by emailing us here</h1>",
  "/routes/error": "<h1>Someday I'll Make an Error Page</h1>"
  };

Inside the createServer() callback, we log the request to the console to help us understand what the program is doing:

console.log("Received an incoming request!");
console.log(req.url);

This next block of code in the callback is actually just a different form of the statements
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");

In this version, we're calling the response object's writeHead() method: this method accepts a status code and a collection of response headers as an object. In this case, we're only adding one response header so our object { } contains one key-value pair: "Content-Type" is the key (the header name) and "text/html" is the value. If you wanted to add multiple headers, you would separate each key-value pair with a comma, just like regular JSON syntax.

res.writeHead(200, {
    "Content-Type": "text/html"
});

Next, we check to see if the request URL is one of the items in our routeResponseMap: if it exists, we add the appropriate heading (the heading that's the value for the req.url key) to the response object by using the res.write() function. Unlike res.end(), this will only write to the response body: it won't send the response. In my version, I also added the request URL for debugging and testing purposes.

if (routeResponseMap[req.url]) {

    res.write(routeResponseMap[req.url]);
    res.end(`<p>Request: ${req.url}</p>`);
} else {
    res.end(`<h1>Welcome Page</h1><p>Request: ${req.url}</p>`);
}

The else block will only execute if the request URL was not one of the items in our routeResponseMap. In this case, we send a default response "Welcome Page" along with the request URL.

Try the program: run npm init and fill in all the values, and then run it with node app

Watch the console while you test the following URLs in your browser:

Each one should show the appropriate heading, although note that the last 2 will show the Welcome Page heading.

Routing Requests for Files

In the previous example we wrote a web application that had only one response to any request to localhost:3000. However, in a real application you would do a lot more. For example:

How would this work? You would check the request's information and then process the request and send a response based on that information. Often you would look for patterns in the URL (e.g. if the URL contains "getyear" or if the request URL is /info). We tried to simulate this with the simple route handler we wrote earlier. However, this program only sent a customized response containing a heading. Ideally we should send an appropriate HTML page. We'll do a simple version of this now.

First, create a new app. I'll call mine /route_handler_better.

Add an app.js file. Add sub-directories /public and /views. We'll use the public directory to contain any CSS and images you want in your application's pages. We'll use /views to store any HTML files that are part of our project.

In the /public directory, add the standards /css and /images directory. Add a CSS file and some images to your directories. In the /views directory, add an index.html and a second .html page. If you prefer, you can use these files: route handler views and public directories zip file (click the Download button on the right beside the "Raw" button).

Now start coding your app.js file: add the constant variables for the port number, the http module, and also the file system (fs) module:

"use strict";

const port = 3000,
    http = require("http"),
    fs = require("fs"); 

This program is going to use two helper functions: The first one will read in a file and send it as a response, or it will send an error response if a file can't be read. The second function simply encapsulates the error response code so we can re-use it.

Paste the two functions into your app.js file. A good review exercise would be to document each statement.

const customReadFile = (path, res) => {

    if (fs.existsSync(path)) {
  
        fs.readFile(path, (error, data) => {

            if (error) {
                console.log(error);
                sendErrorResponse(res, path);
                return;
            } 
            res.write(data);
            res.end();
          });
    } else {  
        sendErrorResponse(res, path);
    }
};  
  
const sendErrorResponse = (res, path) => {
    res.writeHead(404, {
        "Content-Type": "text/html"
    });
    res.write(`<h1>File Not Found!</h1><p>${path}</p>`);
    res.end();
};

The customReadFile() function accepts the file you want to read and the response object. We will have to pass the response object into this function because the function is going to write to the response body and send it.

The fs.existsSync(path) function checks the file system to see if path (a directory/file name) exists. It returns true if it exists and false if it doesn't exist. This allows us to only fetch files that actually exist.

Inside the fs.existsSync() if block, we read the file using the fs.readFile() function we became familiar with in the First Node.js Apps lesson. In this case, we attempt to read the file and if there is a problem reading the file, we log an error to the console and then we invoke the sendErrorResponse() function. I added a return statement afterwards, which exits the customReadFile() function at this point.

If there are no problems reading the file, we write the file to the response object and send the response.

If the file doesn't exist, we invoke the same sendErrorReponse() function. This function takes the provided response object and sends a 404 error response to the client.

Now we can set up the server so that it uses the customReadFile() function whenever a new request comes in. Go ahead and add the createServer() and listen() functions:

http.createServer((req, res) => {

}).listen(port, () => {
    console.log(`The server is listening on port number: ${port}`);
});

Our server will accept a request and then decide which file to return based on the request URL. Recall that when a page contains external resources such as client-side scripts, stylesheets, and images, each resource triggers a new request to the browser: we will need to handle all of these requests: If a file name ends with .html, we will find the appropriate HTML file and send it in the response as content-type text/html. If the file requested ends with .css, we will find the appropriate CSS file and send it in the response as content-type text/css. We will do this for each type of file in our project. Note the image types for the images you chose: the ones I provided are .jpg images but check your own, also. If you have .png or .gif, you'll have to add code to handle those.

First, we should set up variables for the path/name of the file being requested and also the content type header we will eventually set:

let contentType = ""; 
let path = "";

Next, we need to extract the file name from the end of the request URL. We can use split("/") to split the request URL by the forward slash character; the last element will be the file name.

let fileName = req.url.split("/").pop();

Now we'll use a if/elseif block to get the correct path and content type for the file that was requested:

if (req.url.indexOf(".html") >= 0) {
  contentType = "text/html";
  path = `./views/${fileName}`;
} else if (req.url.indexOf(".css") >= 0) {
  contentType = "text/css";
  path =  `./public/css/${fileName}`;
} else if (req.url.indexOf(".jpg") >= 0) {
  contentType = "image/jpg";
  path =  `./public/images/${fileName}`;
} 

Note that if you have other types of files such as .png or .gif, you'll need to add an else-if block for those and set the appropriate content type.

In this if statement, we are ensuring that we set the path to the correct location of the requested file: your urls and references in your code may not exactly match the physical structure, and that's fine. For example, our stylesheets are in ./public/css but the <link> element inside the HTML files have the source as href="css/main.css". This is normal, because a Node.js project is not public, so it doesn't have the same directory structure. The path "css/main.css" or even "./css/main.css" wouldn't work in our project, because all css files are in the /public directory.

Let's add some console output for debugging purposes:

console.log(contentType);
console.log(path);
console.log(fileName);

If we get through this multi-sided if and the contentType is still empty, it means that we didn't find a matching route for the requested file, so we will send the error response.

if (contentType === "") {
  sendErrorResponse(res, path);
} else {

}

But if this is false and we do have something in the contentType variable, it's safe to send a response containing the file and a 200 Ok status code:

if (contentType === "") {
  sendErrorResponse(res, path);
} else {
  res.writeHead(200, {
    "Content-Type": contentType
  });
  customReadFile(path, res); 
}

That's it! Now save everything and if run npm init on your project if you haven't already done it. Then run your program (node app or nodemon app.js or npm start).

Everything should work: you should be able to click links, see the css styling, images, etc.

Now that you've tried this newer version of the routing program, you can probably still see some issues with it:

You can see that this is going to get tedious for even small projects, but also it's just not enough. We need to be able to do more, like POST requests, database/file writes, etc.

To fix this, we can use a framework like Express.js. Express.js can handle POST requests, write to a database or file, and much more. This will allow us to use the MVC (Model View Controller) design pattern create applications with dynamic data and much more functionality. This also means we can write better code for route handling.

Practice Exercises

For each exercise, set up a new project directory.

Run and test each project locally and on your server, if you have access to one.

  1. Create a new Node.js project. Add four HTML files to the project root: about.html, contact.html, error.html, and index.html. Each page has minimal html, a header, and a footer element. The header contains a level-1 heading with the page title (the error page should say "Error: Not Found"). Add an app.js file to your project that contains code that does the following:
    • create a map of routes to the about, contact, and index pages
    • e.g. my project directory is /ex4 so I have /ex4 as the key for index.html, /ex4/contact as the key for contact.html, etc.
    • when your server receives a request, get the file name for the request URL (use your map of routes)
    • if a request URL doesn't have a route, use the error.html page
    • Log all requests to the console for debugging purposes

    Test your application and make sure the correct page loads. For all requests you haven’t mapped, the error page should load.