Using Node.js on the web generally involves a server framework, like Express, Hapi, or Koa. These make working with the underlying HTTP support easier. Sometimes you need the full power of a framework, but in other cases that can be overkill. In this article, we'll ignore the benefits of a framework and look at the underlying features of Node's HTTP module and how you can use it to create a web server. In future articles, we'll examine other features of the HTTP module.

HTTP

http is one of the built-in modules that comes with Node.js. It provides low-level features for making requests as a client, as well as running a server to accept requests and return responses.

While it is full-featured from a technological standpoint, it can be difficult to use compared to many of the abstractions available today.

To better understand the base functionality that you are likely to interact with, let's use the createServer method to create a basic server and handle some common tasks.

💡Note: In this article, we'll focus on http and not https. The https module includes all the core features of http, but with additional options for handling the necessary security differences like certifications.

http.createServer

The createServer method allows Node.js to act as a web server and receive requests. A basic implementation may look like this:

const http = require("http")
const PORT = 3000
const server = http.createServer()

server.listen(PORT, error => {
  if (error) {
    return console.error(error)
  }

  console.log(`Server listening on port ${PORT}`)
})

First, we import http. We also define a constant for the port. This may be familiar if you're used to the syntax of Express.

Next, set server equal to http.createServer. In this case, we call createServer on it's own without any arguments. You can pass in an option object, as well as a callback function to handle the requests. An alternate approach is to listen for events. More on both of these techniques shortly.

Finally, we tell the server to listen on the defined PORT, then pass a callback to handle any errors and print a message letting us know that the server is up and running.

This server doesn't do anything yet. If we try to visit http://localhost:3000 or make a request to it, the request will eventually timeout since the server doesn't know that it needs to respond. Let's fix that by adding in some logic to handle incoming requests.

You can either listen to the request event, or pass a callback function into createServer. We'll show both:

Event-based

const server = http.createServer()
server.on("request", (request, response) => {
  // handle requests
})

Callback on createServer

const server = http.createServer((request, response) => {
  // handle requests
})

To keep things easier to follow, we'll use the second approach for the rest of this article. With that in mind, the techniques will translate.

The callback has request and response. As with any callback function, these names are only how you will reference each argument going forward. They map to http.IncomingMessage and http.ServerResponse respectively. These are all the basis for most third-party frameworks and libraries you may come across. To get a better understanding, let create a GET response to the /cats endpoint and return an array of cat names.

const server = http.createServer((request, response) => {
  // [1]
  const { method, url, headers } = request

  // [2]
  if (method === "GET" && url === "/cats") {
    response.statusCode = 200
    response.setHeader("Content-Type", "application/json")
    const responseBody = {
      headers,
      method,
      url,
      body: ["Mrs. Meowsers", "Hairball", "Jerk"]
    }

    response.write(JSON.stringify(responseBody))
    response.end()
  }
})

The code above does a few things. It pulls out method, url, and headers from the request object (1). Depending on what you need, you may also want to add additional properties to the destructured variables. It also checks if the method and url match what we're looking for (2). Finally, it sets the status code and header, assembles a response body, and writes the body to the response before ending the response. In this case, the response body contains details about the request, as well as the data we want to send back. Before sending the response to the client, we need to stringify the data. This covers a basic GET, but what about more complex options like POST, querystrings, or running an HTML page? Here are a few common examples that can be useful.

Parse a querystring

You can parse query strings a few ways. One technique is to use the built-in url module. For relative paths, it requires the url and the domain.

// Given a request made to: http://localhost:3000/cats?id=1234
require("url")

//...
const { url, headers } = request
let urlParts = new URL(url, `http://${headers.host}`)

console.log(urlParts.searchParams.get("id"))
// => 1234

Take note of the getter method on the end. searchParams returns a URLSearchParams object. Interact with the search parameters through the get and set methods. You can also use the searchParams.has() method to confirm that a property exists before accessing it.

Handling POST and PUT data

Managing incoming data is a little more complex, as incoming request data is a Readable Stream. Streams allow you to process data in chunks. This can be frustrating if you aren't expecting it, especially if you come from a background in more synchronous programming. You can avoid it through a variety of third-party modules from NPM, or process them using request events.

//...
const server = http.createServer((request, response) => {
  let data = []
  request
    .on("data", d => {
      data.push(d)
    })
    .on("end", () => {
      data = Buffer.concat(data).toString()
      response.statusCode = 201
      response.end()
    })
})

/*
Note: For stringified data, you can also use string concatenation:
let data = ""
...
data += d
*/

The request has all the events available to readable streams. You can think of this like sending a book one word at a time, then assembling it. Whenever a "word" comes in, the data event fires and we save the word. Then when the transmission ends, all the parts are put together into the finished book.

Sending HTML

Most of the examples so far have been API focused, but what if you want a route to return HTML?

//...
if (method === "GET" && urlParts.pathname === "/") {
  response.setHeader("Content-Type", "text/html")
  response.statusCode = 200
  response.end("<html><body><h1>Hello, World!</h1></body></html>")
}

The same technique could work with a templating system, as long as the output is a string of HTML.

Wrapping up

For more on creating a server, the official Node.js documentation has an excellent guide on The Anatomy of an HTTP Transaction.

If you plan to only manage a few routes, or even if your goal is to create your own custom-purpose framework, using http might be a good option. Many server frameworks use it under the hood to create their server instances. Express, for example, passes it's core app right into createServer:

app.listen = function listen() {
  var server = http.createServer(this)
  return server.listen.apply(server, arguments)
}

Come back to the Bearer Blog for more about Node.js' HTTP module, and check how you can monitor all third-party APIs and web services at Bearer.