Payload Logo

How to build basic Express.js from scratch [Part1]

Author

Bao Duong

Date Published

If you're already familiar with building user interfaces using HTML, CSS, and JavaScript, and now you want to build the backend for your website, then Express.js is the perfect choice. In this article, we'll go on a journey to build a basic Express.js-like framework from scratch. Throughout the process, you'll learn how Express.js handles routing, middleware, and HTTP requests, helping you seamlessly and flexibly connect the frontend to the server system.

1. What is Node.js? Why we choose Express.js?

Node.js is a server-side platform for running JavaScript, allowing developers to use JavaScript not just in the browser but also on the backend. With asynchronous handling and high performance, Node.js is a popular choice for building web apps, APIs, microservices, and more. However, Node.js itself doesn’t provide a built-in framework to make web app development easy — that’s where Express.js comes in.

Express.js is a lightweight yet powerful framework for Node.js, making it easier to build web applications and APIs. It allows you to define HTTP request routes intuitively and provides a middleware mechanism for features like authentication, logging, and error handling. This makes connecting the frontend with the server smoother than ever.

To rebuild Express.js from scratch, you need some foundational knowledge. First, understand how Node.js works — from the event loop to non-blocking I/O — and get comfortable with JavaScript programming concepts like async/await and arrow functions. You should also understand HTTP protocols, methods like GET, POST, PUT, DELETE, and how requests and responses work. Lastly, I recommend spending some time getting started with Express.js to make the coding process smoother.

2. Setting up the Environment

2.1. Installing Node.js

Express.js is built on Node.js, so you’ll need to install Node.js first. You can download it from the official site: Node.js.org. After installation, open your terminal and check the version using:

1node -v
2npm -v

If versions are displayed, Node.js has been successfully installed.

2.2. Initialize the Project

Next, create a directory for your project and initialize a new Node.js project with npm. In the terminal:

1mkdir <your_project_name>
2cd <your_project_name>
3npm init -y

The npm init -y command creates a package.json file with default settings, allowing you to manage packages for the project. Alternatively, use npm init to manually configure it.

2.3. Installing Additional Packages

Node.js includes several core modules like http, url, and querystring. These can be used directly without installation. However, tools like nodemon — which automatically restarts the server on code changes — need to be installed separately since they are not part of the core modules. Install nodemon with:

1npm install --save-dev nodemon

Installing with the --save-dev flag adds the package to the devDependencies section in package.json, meaning it’s only used during development and isn’t needed in production. For example, nodemon is helpful during development but in production, the server would run with Node.js directly.

2.4. Project Folder Structure

The structure below helps you organize code clearly and makes scaling easier. We'll explain each part in detail in the following sections:

1your-project-name/
2-- node_modules/ # Installed libraries
3-- public/ # Static files (images, css, js, html,...)
4-- src/
5│ │-- routes/ # API routes
6│ │-- middleware/ # Custom middleware
7│ │-- static/ # Render static files
8│ │-- app.js # App configuration
9-- index.js # Server entry point
10-- package.json # Package configuration
11-- package-lock.json # Package-lock file
12

2.5. Configuring package.json

After running npm init -y, the default package.json looks like this:

1{
2 "name": "your-project-name",
3 "version": "1.0.0",
4 "main": "index.js",
5 "scripts": {
6 "test": "echo \"Error: no test specified\" && exit 1"
7 },
8 "keywords": [],
9 "author": "",
10 "license": "ISC",
11 "type": "commonjs",
12 "description": ""
13}

Let’s modify the scripts section to add a start command using nodemon:

1"scripts": {
2 "start": "nodemon index.js"
3}

Explanation of key fields:

  • name: Project name.
  • version: Project version.
  • main: Entry file.
  • scripts: Scripts you can run via npm run <script>.
    • start: Runs the server with nodemon.
  • keywords: Keywords describing the project.
  • author: Project author.
  • license: License for the project.
  • type: How Node handles modules:
    • "commonjs" uses require() and module.exports.
    • "module" uses ESM (import, export).
  • description: Short project description.

After updating, start your server with:

1npm run start

At this point, your basic project setup is complete and ready for building your own Express.js-like framework.

3. Building the Application

With the environment ready, now we’ll build the App class — the core of our framework. This class will initialize the HTTP server, integrate the routing system, and handle middleware in a simple yet effective way. Start coding in app.js from step 2.4.

3.1. Import Required Modules

Import Node.js’s built-in http module to create the server, and import Router (which we’ll build later) to manage routes and map HTTP requests to handlers:

1const http = require("http");
2const Router = require("./routes/router");

3.2. Initialize App

In the constructor, we create a Router instance and a middlewares array to hold user-defined middleware. This enables easily adding logic (logging, body parsing, authentication, etc.) before reaching the final handler.

1class App {
2 constructor() {
3 this.router = new Router();
4 this.middlewares = [];
5 }

3.3. Define Methods

Registers middleware by pushing it into the middlewares array. This mimics how Express handles middleware.

1 use(middleware) {
2 this.middlewares.push(middleware);
3 }

The get and post methods let users define routes for corresponding HTTP methods by calling this.router.addRoute. This maps the request to the appropriate handler. (Only GET and POST are implemented here for simplicity.)

1 get(path, handler) {
2 this.router.addRoute("GET", path, handler);
3 }
4
5 post(path, handler) {
6 this.router.addRoute("POST", path, handler);
7 }

listen method starts the HTTP server using http.createServer. For each request, it sequentially runs all middleware using a next() mechanism:

  • If more middleware exists, the next one runs.
  • Once done, this.router.handle(req, res) takes over.

Finally, server.listen starts the server on the given port.

1 listen(port, callback) {
2 const server = http.createServer((req, res) => {
3 let i = 0;
4 const next = () => {
5 if (i < this.middlewares.length) {
6 return this.middlewares[i++](req, res, next);
7 }
8 this.router.handle(req, res);
9 };
10 next();
11 });
12 server.listen(port, callback);
13 }
14}
15
16module.exports = App;

3.4. Summary

We’ve now built the core App class, which initializes the server, manages middleware, and handles basic routing. Next, we’ll implement the Router system to route requests to the correct handlers.

4. Router System

This section focuses on building a simple Router system to handle incoming requests and dispatch them to the appropriate handlers based on HTTP methods and paths. Create router.js inside the routes folder mentioned in step 2.4.

4.1. Initialize Router

We create a Router class with a routes object that groups routes by HTTP method.

1class Router {
2 constructor() {
3 this.routes = {};
4 }

Example routes structure:

1{
2 "GET": { "/": handlerFunction, "/about": handlerFunction },
3 "POST": { "/login": handlerFunction }
4}

This allows fast and organized route lookup.

4.2. Define Methods

Registers a new route with a method, path, and handler. If no route group exists for a method, it initializes it.

1 addRoute(method, path, handler) {
2 if (!this.routes[method]) {
3 this.routes[method] = {};
4 }
5 this.routes[method][path] = handler;
6 }

Handles incoming requests by:

  • Extracting the pathname from the URL.
  • Getting the HTTP method from req.method.
  • Looking up the appropriate handler.
  • Returning the handler's result or a 404 error.
1 handle(req, res) {
2 const { pathname } = new URL(req.url, `http://${req.headers.host}`);
3 const method = req.method;
4 if (this.routes[method] && this.routes[method][pathname]) {
5 return this.routes[method][pathname](req, res);
6 }
7 res.statusCode = 404;
8 res.end('Not Found');
9 }
10}
11
12module.exports = Router;

4.3. Summary

We’ve built a simple Router system that maps HTTP methods and paths to request handlers. Though basic, it demonstrates how Express.js routing works. Next, we’ll create the index.js entry point to test the server.

5. Setting Up Entry Point

After building the Router, the next step is to configure index.js as the entry point. This file will launch the server and register routes for testing.

5.1. Initialize App

Import the App class from src and create an instance:

1const App = require("./src/app");
2const app = new App();

5.2. Define Routes

Register a simple route at / that returns "Hello World":

1app.get("/", (req, res) => {
2 res.end("Hello World");
3});

5.3. Start Server

Start the server on port 3000 and log a message when it’s ready:

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

5.4. Result

After running:

1npm run start

You’ll see a message in the terminal:

express_terminal.png

Visiting http://localhost:3000/ or using cURL:

1curl http://localhost:3000/

You will receive:

1Hello World

This confirms the server is running successfully.

6. Conclusion of Part 1

In this part, we covered the basic steps to rebuild Express.js from scratch, including:

  • Environment Setup: Installing Node.js and initializing the project.
  • Building the App: Creating the App class to manage the app.
  • Building the Router: Creating a routing system for handling requests.
  • Starting the Server: Creating the entry point and testing routes.

These steps gave you an inside look at how Express.js works under the hood. In the next part, we’ll dive deeper into middleware handling and static file serving to expand the app’s capabilities.

You can follow the project and updates via the GitHub repository.

7. References