Constructing a REST API with Node.js: A Step-by-Step Guide
Express.js frequently emerges as the leading choice among Node.js frameworks when crafting the backend for a REST API. Although it also facilitates the creation of static HTML and templates, our focus in this series will be on backend development leveraging TypeScript. The end product will be a REST API equipped to handle queries from any frontend framework or external backend service.
Prerequisites for this endeavor include:
- Fundamental understanding of JavaScript and TypeScript
- Basic familiarity with Node.js
- Rudimentary knowledge of REST architecture (refer to the relevant section of my prior REST API article for a refresher if required)
- A functional Node.js installation (preferably version 14 or later)
Initiating the project involves creating a dedicated folder within your terminal (or command prompt). Navigate to this folder and execute npm init. This command will guide you through the process of generating That will create.
Our next step is to incorporate the Express.js framework and a few auxiliary libraries:
| |
These libraries have earned their place as Node.js developer favorites for good reasons:
debugserves as a module that allows us to circumvent the use ofconsole.log()during application development. This practice enables effortless filtering of debug statements when troubleshooting. Moreover, these statements can be completely deactivated in production, eliminating the need for manual removal.winstontakes charge of logging requests to our API, along with the corresponding responses and any errors encountered. Theexpress-winstonmodule seamlessly integrates with Express.js, preconfiguring all standard API-relatedwinstonlogging code.corsfunctions as Express.js middleware, allowing us to enable cross-origin resource sharing. Without it, our API’s usability would be restricted to frontends originating from the exact subdomain as our backend.
While our backend utilizes these packages during runtime, our TypeScript configuration necessitates the installation of specific development dependencies. To accomplish this, execute the following command:
| |
These dependencies are essential for enabling TypeScript within our app’s codebase. They also encompass the types utilized by Express.js and other dependencies. This foresight can significantly expedite development when working with IDEs like WebStorm or VSCode by providing automatic function method completion during coding.
Upon completion, the dependencies section of your package.json file should resemble the following:
| |
With all necessary dependencies installed, we can proceed to construct our codebase.
Structural Blueprint for a TypeScript REST API Project
This tutorial will guide you through the creation of three essential files:
./app.ts./common/common.routes.config.ts./users/users.routes.config.ts
The rationale behind employing two folders (common and users) within the project structure is to establish distinct modules, each bearing its own set of responsibilities. Ideally, each module would eventually house some or all of the following elements:
- Route Configuration: Defines the range of requests manageable by our API.
- Services: Handles tasks such as connecting to database models, executing queries, or establishing connections with external services as required by specific requests.
- Middleware: Executes designated request validations before the final route controller takes over.
- Models: Defines data models that align with a given database schema, streamlining data storage and retrieval.
- Controllers: Decouples route configuration from the code responsible for ultimately processing route requests (after any middleware intervention), invoking the aforementioned service functions as needed, and generating responses to the client.
This folder structure provides a basic REST API design, a solid foundation for the remainder of this tutorial series, and a suitable framework for practical application.
Crafting a Common Routes File with TypeScript
Within the common folder, create a file named common.routes.config.ts and populate it with the following code:
| |
The approach to route creation demonstrated here is optional. However, given our use of TypeScript, this routes scenario presents a prime opportunity to practice inheritance using the extends keyword, as we’ll soon see. In this project, all route files adhere to a consistent pattern: they possess a name (utilized for debugging) and maintain access to the central Express.js Application object.
With this groundwork laid, we can shift our attention to creating the users route file. Within the users folder, create a file named users.routes.config.ts and initiate its code as follows:
| |
Here, we import the CommonRoutesConfig class and extend it to our newly created UsersRoutes class. Through the constructor, we supply the app (the main express.Application object) and the name UsersRoutes to CommonRoutesConfig’s constructor.
While this example remains relatively simple, this practice of extending the base class proves invaluable when scaling to accommodate numerous route files, as it effectively prevents code duplication.
Consider a scenario where we aim to introduce new features, such as logging, to this file. This could be achieved by incorporating the necessary field into the CommonRoutesConfig class, thereby granting access to all routes inheriting from CommonRoutesConfig.
Leveraging TypeScript Abstract Functions for Cross-Class Functionality Similarities
What if we desire certain functionalities to be similar across these classes, such as API endpoint configuration, but necessitate distinct implementations for each class? TypeScript offers a solution in the form of abstraction.
Let’s construct a rudimentary abstract function that the UsersRoutes class (and any future routing classes) will inherit from CommonRoutesConfig. Our objective is to enforce the inclusion of a function named configureRoutes() in all routes. This function will serve as the designated location for declaring the endpoints of each routing class’s resource.
To accomplish this, we’ll make three concise additions to common.routes.config.ts:
- Append the keyword
abstractto ourclassdeclaration, thereby enabling abstraction for this class. - Introduce a new function declaration at the end of our class:
abstract configureRoutes(): express.Application;. This enforces the implementation of a matching signature in any class extendingCommonRoutesConfig—failure to do so will trigger an error from the TypeScript compiler. - Insert a call to
this.configureRoutes();at the constructor’s end, as the existence of this function is now guaranteed.
The resulting code:
| |
With this modification, any class inheriting from CommonRoutesConfig must now include a function named configureRoutes() that returns an express.Application object. Consequently, our users.routes.config.ts file requires an update:
| |
Let’s recap our accomplishments:
We began by importing the common.routes.config file, followed by the express module. Subsequently, we defined the UserRoutes class, specifying its extension of the CommonRoutesConfig base class. This implies our commitment to implementing the configureRoutes() function within this class.
To facilitate information transfer to the CommonRoutesConfig class, we utilize the class’s constructor. This constructor anticipates receiving the express.Application object, which will be elaborated upon in the next step. We then employ super() to transmit the application and the name of our routes (in this case, UsersRoutes) to CommonRoutesConfig’s constructor. In turn, super() will invoke our implementation of configureRoutes().
Configuring the Express.js Routes for User Endpoints
The configureRoutes() function serves as the staging ground for defining the endpoints catering to users of our REST API. Here, we’ll harness the capabilities of the application and its associated route functionalities provided by Express.js.
Our rationale for utilizing the app.route() function stems from our desire to minimize code redundancy, a straightforward endeavor given our focus on building a REST API with well-defined resources. In this tutorial, our primary resource is users. Consequently, we encounter two scenarios:
- When the API caller intends to create a new user or retrieve a list of all existing users, the endpoint’s path should conclude with
users. (This article will not delve into query filtering, pagination, or similar queries.) - When the caller seeks to interact with a specific user record, the request’s resource path will adhere to the
users/:userIdpattern.
Express.js’s .route() implementation enables elegant handling of HTTP verbs through chaining. This is because functions like .get(), .post(), and so forth, return the same IRoute instance as the initial .route() call. Our final configuration will take the following form:
| |
This code grants any REST API client the ability to interact with our users endpoint using either a POST or a GET request. Similarly, clients can target our /users/:userId endpoint using GET, PUT, PATCH, or DELETE requests.
However, in the case of /users/:userId, we’ve introduced generic middleware using the all() function, which will execute before any of the get(), put(), patch(), or delete() functions. This function will prove particularly useful when we implement routes accessible only to authenticated users later in this series.
You may have observed that our .all() function, like any middleware, utilizes three types of fields: Request, Response, and NextFunction.
- The Request serves as Express.js’s representation of the incoming HTTP request. This type extends and enhances the native Node.js request type.
- Similarly, the Response represents the HTTP response in Express.js, building upon the native Node.js response type.
- Equally crucial is the
NextFunction, acting as a callback function that facilitates control flow through other middleware functions. Throughout this process, all middleware components share the same request and response objects before the controller ultimately transmits a response back to the requester.
Constructing the Node.js Entry Point: app.ts
Having established basic route skeletons, we can now configure our application’s entry point. Create a file named app.ts at the root level of your project folder and initiate it with the following code:
| |
At this juncture, only two imports are new:
httpis a native Node.js module essential for launching our Express.js application.body-parseracts as middleware bundled with Express.js. Its role is to parse the incoming request (in this case, as JSON) before passing control to our custom request handlers.
With the necessary files imported, let’s declare the variables we intend to utilize:
| |
The express() function returns the primary Express.js application object, which will be passed across our codebase, beginning with its addition to the http.Server object. (We’ll initiate the http.Server after configuring our express.Application.)
We opt to listen on port 3000—which TypeScript will automatically infer is a Number—instead of the conventional ports 80 (HTTP) or 443 (HTTPS) because the latter are typically reserved for an application’s frontend.
Why Port 3000?
There is no rule that the port should be 3000—if unspecified, an arbitrary port will be assigned—but 3000 is used throughout the documentation examples for both Node.js and Express.js, so we continue the tradition here.
Can Node.js Share Ports With the Front End?
We can still run locally at a custom port, even when we want our back end to respond to requests on standard ports. This would require a reverse proxy to receive requests on port 80 or 443 with a specific domain or a subdomain. It would then redirect them to our internal port 3000.
The routes array will maintain a record of our route files, aiding in debugging, as we’ll soon demonstrate.
Lastly, debugLog will function similarly to console.log, but with enhanced capabilities. Its fine-tuning process is more straightforward due to its automatic scoping to our desired file/module context. (In this instance, we’ve designated it as “app” by passing it as a string to the debug() constructor.)
We’re now prepared to configure all Express.js middleware modules and define our API’s routes:
| |
The expressWinston.logger seamlessly integrates with Express.js, automatically logging comprehensive details—using the same infrastructure as debug—for each completed request. The options provided to this logger ensure well-formatted and color-coded terminal output, with increased verbosity (default behavior) when operating in debug mode.
Note that our route definitions must be placed after we set up expressWinston.logger.
Finally, and most importantly:
| |
This code snippet is responsible for launching our server. Once operational, Node.js will execute our callback function, which, in debug mode, reports the names of all configured routes—currently, only UsersRoutes. Following this, our callback signals the backend’s readiness to handle requests, even when running in production mode.
Updating package.json: Transpiling TypeScript to JavaScript and Running the Application
With our skeletal structure ready for execution, we need to incorporate some boilerplate configuration to enable TypeScript transpilation. Create a file named tsconfig.json at the project’s root level:
| |
Our final step involves adding the finishing touches to package.json in the form of the following scripts:
| |
The test script serves as a placeholder, which we’ll replace later in this series.
The tsc command within the start script originates from TypeScript. It handles the transpilation of our TypeScript code into JavaScript, outputting the result into the dist folder. Subsequently, we execute the built version using node ./dist/app.js.
We pass the --unhandled-rejections=strict flag to Node.js (even in versions 16 and above) because, in practice, debugging using a direct “crash and display the stack” approach proves more straightforward than relying on sophisticated logging mechanisms with an expressWinston.errorLogger object. This holds true even in production environments, where allowing Node.js to continue running despite an unhandled rejection is likely to leave the server in an unpredictable state, potentially leading to more intricate bugs.
The debug script invokes the start script but preemptively defines a DEBUG environment variable. This activates all our debugLog() statements (including similar statements from Express.js, which shares the same debug module), generating valuable details in the terminal—details that remain conveniently hidden when the server runs in production mode using a standard npm start command.
Experiment by running npm run debug and subsequently compare the console output with that of npm start to observe the differences.
Tip: You can restrict the debug output to our app.ts file’s debugLog() statements by using DEBUG=app instead of DEBUG=*. The debug module offers considerable flexibility, and this feature is no exception.
Windows users may need to replace export with SET, as export is the standard on Mac and Linux systems. If your project needs to accommodate multiple development environments, the cross-env package provides a simple and effective solution.
Testing the Live Express.js Backend
With npm run debug or npm start running, our REST API is primed to handle requests on port 3000. At this point, tools like cURL, Postman, Insomnia, or any other preferred method can be used to test the backend.
Given that we’ve only established a skeletal framework for the users resource, we can send requests without bodies to verify that everything functions as expected. For example:
| |
Our backend should respond with the message “GET requested for id 12345”.
Similarly, for POST requests:
| |
This, along with all other request types for which we’ve created skeletons, will exhibit similar behavior.
Ready for Rapid Node.js REST API Development with TypeScript
This article guided you through the initial stages of REST API development, encompassing project configuration from scratch and an exploration of Express.js framework fundamentals. We then embarked on our TypeScript journey by establishing a pattern with UsersRoutesConfig extending CommonRoutesConfig, a pattern we’ll revisit in subsequent installments of this series. Finally, we configured our app.ts entry point to utilize our newly created routes and updated package.json with scripts for building and running the application.
However, even the fundamental aspects of a REST API built with Express.js and TypeScript encompass considerable complexity. The next part of this series will delve into crafting proper controllers for the users resource and explore valuable patterns for services, middleware, controllers, and models.
The complete project is available on GitHub, and the code representing the state at the end of this article can be found in the toptal-article-01 branch.