Build A professional CRUD REST API with Node.js Typescript and MongoDB

Node.js/TypeScript/MongoDB REST API

Unlock robust backend capabilities with this guide on building a professional CRUD REST API using Node.js, TypeScript, and MongoDB. Dive deep into modern development techniques, ensure type safety with TypeScript, and seamlessly integrate with MongoDB for efficient data operations.

In the fast-paced realm of web development, the backend is the unsung hero, silently managing, storing, and serving data to users. So writing a flexible, maintainable and well organized backend code is an absolute necessity. In this article we are going to take a step by step approach to write a Node.js REST API for a CRUD Application with typescript as a language and MongoDB as database. Along the way we will try to explain the code snippet as much as possible so that things gets much clearer at the end of this article

Prerequisites

  1. Basic knowledge of JavaScript/TypeScript.
  2. Node.js and npm installed.
  3. A MongoDB Atlas account (Don’t worry if you are not familiar with it this article will guide you to setup one)

Step-1 Setting up the Project

Open your command prompt and change the directory where you want to store the project and then type the following to create the project directory . These commands are mostly windows based if you are using different OS please use the equivalent commands

mkdir nodejs-typescript-mongodb-rest-api && cd nodejs-typescript-mongodb-rest-api

Once you are inside the directory use the following command to create the package.json file which is the file where Node.js will store all the dependencies and different commands/scripts to run the project or unit tests

npm init -y

It will generate the basic package.json file.

Step-2 Installing Dependencies

Now we need to install the required primary dependencies first and then the dev dependencies

npm install express config mongoose

npm install -D typescript ts-node @types/node @types/express @types/config

Step-3 Creating Typescript configuration file

The following command will create the tsconfig.json file

tsc --init

or 

npx tsc --init

Once you are done creating this just remove the unnecessary properties from the json so that the tsconfig.json should look something like the following

{
  "compilerOptions": {
    "target": "es5",                          
    "module": "commonjs",                    
    "lib": ["es6"],                     
    "allowJs": true,
    "outDir": "build",                          
    "rootDir": "src",
    "strict": true,         
    "noImplicitAny": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  }
}

There are few things we need to understand in the tsconfig.json file. “rootDir” is the property which stores the directory which will store our source code in our case it is going to be “src” . You can choose to store the code in the root of your project folder also , depends on your choice. Next property that we are interested in is “outDir” which tells the compiler where to store the compiled js files. Here the value is “build” . So after compilation/transpilation the source files are going to be stored in the “build” folder from where they will run. We are going to use commonjs module, it is mainly used in server-side JS apps with Node. The property “noImplicitAny” : true will tell the Typescript that it must throw an error if no explicit type declaration is missing from any variable

Step-4 Dive into coding creating the server

Choose your editor and open the project folder in the editor. After opening the project folder in the code editor create a folder “src” in the root of the project and then create another folder “config” in the root of the project directory in the same level as “src”. Within “config” folder create a file default.json and put the following code inside it

{
  "PORT":4000
}

then create another file config.ts in the root of src folder and put the following code inside it

import config from 'config'

export const APPCONFIG ={
    PORT : config.get('PORT')
}

Now create a file app.ts in the root of src folder and put the following code inside it

import express, { Application } from 'express'
import { APPCONFIG } from './config';
const app:Application = express();
const PORT = APPCONFIG.PORT;

app.listen(PORT,()=>{
    console.log(`Server started at PORT ${PORT}`)
})


Now in package.json put a script “dev”: “tsc && node build/app.js” under the script and now the script property looks like the following

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "tsc && node build/app.js"
  },

Once this is done we are ready to run our application for the first time.

Go to command prompt and then change your directory to go to the project directory or if you have opened the project in the Visual Studio Code you can open the console and run the following command

npm run dev

if all are good you should see something like this in the console

Server started at PORT 4000

If you see this in your console that means that preliminary setup of your project is done and you can now concentrate on setting up the database

Step-5 Setting up the database in MongoDB Atlas

If you are already familiar with how to set up the database with MongoDB Atlas please skip this step.

Please checkout the below article for how to setup the MongoDB Atlas in detail. Remember to keep the name of the DB as ‘product_db’ so that you can follow along this article

Next we will try connecting the database from our code.

Step-6 Testing the database connectivity

Open up the “default.json” file we created earlier in the “config” directory and enter your database creds like the following. Replace the <USERNAME> ,<PASSWORD> and <DBNAME> with your database username, password and database name . (Don’t forget to enclose the values with double quotes )

{
    "PORT":4000,
    "DB_USER":<USERNAME>,
    "DB_PASS":<PASSWORD>,
    "DB_NAME":<DBNAME>
}

Next open config.ts file which we have created earlier and put the following code inside it. The current config file should look like the following

import config from 'config'

export const APPCONFIG ={
    PORT : config.get('PORT')
}

export const DBCONFIG ={
    DB_USER: config.get('DB_USER'),
    DB_PASS: config.get('DB_PASS'),
    DB_NAME: config.get('DB_NAME')
}

  

Now create a file called dbconnect.ts in the root of src folder and then put the following code inside it

import mongoose from 'mongoose'
import { DBCONFIG } from './config'
export async function connect(){
    try{
        mongoose.connect(`mongodb+srv://${DBCONFIG.DB_USER}:${DBCONFIG.DB_PASS}@cluster0.gir7nvk.mongodb.net/${DBCONFIG.DB_NAME}?retryWrites=true&w=majority`)
        console.log(`DB Connected Successfully`);
    }catch(err:any){
        console.log(`DB Connection Failed with error - ${err.message}`);
    }
}

Now inside the app,ts file we are going to call this connect() function and test database connection . After importing and calling the connect() function our app.ts file should look like the following

import express, { Application } from 'express'
import { APPCONFIG } from './config';
import { connect } from './dbconnect';

const app:Application = express();
const PORT = APPCONFIG.PORT;

connect()

app.listen(PORT,()=>{
    console.log(`Server started at PORT ${PORT}`)
})


Now we are ready to test our database also. Enter the following command your console and you should see the following two messages “Server started at PORT 4000 “ and “DB Connected Successfully”.

npm run dev

Now are basic setup is ready in the coding side and next step is to create 2 CRUD services one for Product Category and another for Products. So let’s jump into tha

Step-7 Setting up the Model for mongodb

Create a folder ‘models’ under src directory and we are going to create two files under namely ‘product.model.ts’ and ‘product.schema.ts’. The first file ‘product,model.ts’ will serve as a data structure for the data to be provided to the models from our node.js application and second file ‘product.schema.ts’ will serve as a schema for MongoDB. Let’s open the first file ‘product.model.ts’ and put the following code inside it

import { Document, Model, Types} from 'mongoose'

export interface IProduct{
    product_name: string,
    product_category_id: Types.ObjectId,
    product_code: string,
    product_price: number,
    product_image?: string
}

export interface IProductCategory{
    product_category_name: string
    parent_category_id?: Types.ObjectId,
    is_active: boolean
}

export interface IProductDocument extends IProduct, Document{}
export interface IProductCategoryDocument extends IProductCategory, Document{}

export interface IProductModel extends Model<IProductDocument>{
    buildProduct(product: IProduct):IProductDocument
    listProducts():Promise<IProductDocument[]>
    getProduct(product_id: Types.ObjectId):Promise<IProductDocument | null>
    updateProduct(product_id: Types.ObjectId, product: IProduct):Promise<IProductDocument>
    deleteProduct(product_id: Types.ObjectId):Promise<IProductDocument | null>
}

export interface IProductCategoryModel extends Model<IProductCategory>{
    buildProductCategory(product_category: IProductCategory):IProductCategoryDocument
    listProductCategory():Promise<IProductCategoryDocument[]>
    getPorudctCategory(product_category_id: Types.ObjectId):Promise<IProductCategoryDocument>
    updateProductCategory(product_category_id: Types.ObjectId, product_category: IProductCategory): Promise<IProductCategoryDocument>
    deleteProductCategory(product_category_id: Types.ObjectId): Promise<IProductCategoryDocument | null>
}

Within the code we have created the input data structure IProductCategory for product category and IProduct for Product . The Interface IProductCategoryDocument and IProductDocument will be the data structure for the output of all the CRUD operations. Both the interfaces extends Document because MongoDB will return the data in form of documents. The IProductCategoryModel and IProductModel extends the Model and includes the operations that we are going to perform on the Model.

Next we are going to create the Schema for MongoDB and create the logic for all the methods that we have created under Models in ‘product.model.ts’ in the file ‘product.schema.ts’

import { Schema, model, Types} from 'mongoose'
import { IProduct,IProductDocument,IProductCategory,IProductCategoryDocument, IProductModel, IProductCategoryModel } from './product.model'

const ProductCategorySchema = new Schema({
    product_category_name:{
        type: String,
        required: true
    },
    parent_category_id:{
        type: Types.ObjectId,
        required:false
    },
    is_active:{
        type: Boolean,
        required: true,
        default: true
    }
})

const ProductSchema = new Schema({
    product_name:{
        type: String,
        required: true
    },
    product_category_id:{
        type: Types.ObjectId,
        required: true
    },
    product_price:{
        type: Number,
        required: true
    },
    product_code:{
        type: String,
        required: true
    },
    product_image:{
        type: String,
        required: false
    }


});

ProductSchema.statics.buildProduct=(product: IProduct): IProductDocument=>{
    return new Product(product)
}

ProductSchema.statics.listProducts=async(): Promise<IProductDocument[]>=>{
    return await Product.find();
}
ProductSchema.statics.getProduct=async(product_id: Types.ObjectId): Promise<IProductDocument | null>=>{
    return await Product.findById(product_id)
}

ProductSchema.statics.updateProduct=async(product_id: Types.ObjectId, product: IProduct): Promise<IProductDocument | null>=>{
    return await Product.findByIdAndUpdate(product_id, product);
}

ProductSchema.statics.deleteProduct=async(product_id: Types.ObjectId): Promise<IProductDocument| null>=>{
    return await Product.findByIdAndRemove(product_id);
}

const Product = model<IProductDocument, IProductModel>('products',ProductSchema);


ProductCategorySchema.statics.buildProductCategory=(product_category: IProductCategory): IProductCategoryDocument =>{
    return new ProductCategory(product_category);
}

ProductCategorySchema.statics.listProductCategory=async(): Promise<IProductCategoryDocument[]>=>{
    return await ProductCategory.find();
}

ProductCategorySchema.statics.getPorudctCategory=async(product_category_id: Types.ObjectId): Promise<IProductCategoryDocument | null >=>{
    return await ProductCategory.findById(product_category_id);
}

ProductCategorySchema.statics.updateProductCategory= async(product_category_id: Types.ObjectId, product_category: IProductCategory): Promise<IProductCategoryDocument | null> =>{
    return await ProductCategory.findByIdAndUpdate(product_category_id,product_category);
}

ProductCategorySchema.statics.deleteProductCategory = async(product_category_id: Types.ObjectId): Promise<IProductCategoryDocument | null> =>{
    return await ProductCategory.findByIdAndRemove(product_category_id);
}

const ProductCategory = model<IProductCategoryDocument, IProductCategoryModel>('product_categories',ProductCategorySchema);

export {
    Product,
    ProductCategory
}

As you can see we have created a the ProdcutSchema, ProductCategorySchema and then provided the logic for each method that we have declared in our previous code. Each method is self explanatory. Lastly we have created the Models Product and ProductCategory which we are going to interact with for database operations.

Now we are going to create a service layer which will abstract all our database operation and work like a DAL(Data Access Layer)

Step-8 Creating our Service Layer

In this step we are going to create a Service Layer. So let’s create a folder called ‘services’ under the ‘src’ folder and also create a file ‘product.service.ts’. Open the file in code editor and put the following code inside it.

import { IProduct, IProductCategory, IProductCategoryDocument, IProductDocument } from '../models/product.model';
import {Product, ProductCategory } from '../models/product.schema';
import { Types } from 'mongoose'

export async function createProductService(product: IProduct): Promise<IProductDocument>{
    try{
        if(!product.product_name)
            throw new Error(`Please enter product name`);
        if(!product.product_code)
            throw new Error(`Please enter product code`);
        if(!product.product_category_id)
            throw new Error(`Please enter product category`);
        if(!product.product_price)
            throw new Error(`Please enter product price`)
        
        const newProduct: IProductDocument = Product.buildProduct(product);
        return await newProduct.save(); 
    }catch(err:any){
        throw new Error(`Something went wrong - ${err.message}`);
    }
}

export async function listProductsService():Promise<IProductDocument[]>{
    try{
        return Product.listProducts();
    }catch(err:any){
        throw new Error(`Something went wrong - ${err.message}`)
    }
}


export async function getProductService(product_id:Types.ObjectId):Promise<IProductDocument|null>{
    try{
        if(!product_id)
            throw new Error(`Please enter product id`);
        return Product.getProduct(product_id);
    }catch(err:any){
        throw new Error(`Something went wrong - ${err.message}`)
    }
}

export async function updateProductService(product_id: Types.ObjectId, product: IProduct): Promise<IProductDocument | null>{
    try{
        if(!product_id)
            throw new Error(`Please enter product id`);
        return Product.updateProduct(product_id,product);
    }catch(err:any){
        throw new Error(`Something went wrong -${err.message}`);
    }
}

export async function deleteProductService(product_id: Types.ObjectId): Promise<IProductDocument| null>{
    try{
        if(!product_id)
            throw new Error(`Please enter product id`);
        return Product.deleteProduct(product_id);
    }catch(err:any){
        throw new Error(`Something went wrong-${err.message}`);
    }
}

export async function createProductCategoryService(product_category: IProductCategory):Promise<IProductCategoryDocument>{
    try{
        if(!product_category.product_category_name)
            throw new Error(`Please enter product category name`);
        if(product_category.parent_category_id)
            product_category.parent_category_id = new Types.ObjectId(product_category.parent_category_id)
        const newProductCategory = ProductCategory.buildProductCategory(product_category);
        return await newProductCategory.save();
    }catch(err:any){
        throw new Error(`Something went wrong - ${err.message}`);
    }
}

export async function listProductCategoryService(): Promise<IProductCategoryDocument[]>{
    try{
        return ProductCategory.listProductCategory();
    }catch(err:any){
        throw new Error(`Something went wrong- ${err.message}`);
    }
}

export async function getProductCategoryService(product_category_id: Types.ObjectId): Promise<IProductCategoryDocument | null>{
    try{
        if(!product_category_id)
            throw new Error(`Please enter product category id`);
        return ProductCategory.getPorudctCategory(product_category_id);
    }catch(err:any){
        throw new Error(`Something went wrong -${err.message}`)
    }
}

export async function updateProductCategoryService(product_category_id: Types.ObjectId, product_category: IProductCategory): Promise<IProductCategoryDocument| null>{
    try{
        if(!product_category_id)
            throw new Error(`Please enter product category id`)
        return ProductCategory.updateProductCategory(product_category_id, product_category);
    }catch(err:any){
        throw new Error(`Something went wrong -${err.message}`);
    }
}

export async function deleteProductCategoryService(product_category_id: Types.ObjectId): Promise<IProductCategoryDocument| null >{
    try{
        if(!product_category_id)
            throw new Error(`Please enter product category id`);
        return ProductCategory.deleteProductCategory(product_category_id);
    }catch(err:any){
        throw new Error(`Something went wrong - ${err.message}`);
    }
}

In this file we have created an abstraction for all the database operation for each of the CRUD operation and functions from this file will be called wherever we need to interact with the database.

Step-9 Creating the controller

Create a folder ‘controllers’ under the ‘src’ folder and create two files ‘product_category.controller.ts’ and ‘product.controller.ts’. We will write our business logic inside the controllers. Let’s first open the file product_category.controller.ts and put the following code inside it

import express, {Request, Response} from 'express';
import { createProductCategoryService, listProductCategoryService, getProductCategoryService, updateProductCategoryService, deleteProductCategoryService} from '../services/product.service';
import { IProduct, IProductCategory, IProductCategoryDocument } from '../models/product.model';
import { Types } from 'mongoose'
export async function createProductCategory(req:Request, res:Response){
    try{
       const input_product_category: IProductCategory = req.body.product_category
       const saved_product_category = await createProductCategoryService(input_product_category);
       return res.status(201).json({
        message:"Prodict Category Created Successfully",
        product_category: saved_product_category
       }) 
       
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product_category:null
        })
    }
}

export async function listProductCategory(req:Request, res:Response){
    try{
        const product_categories : IProductCategoryDocument[] = await listProductCategoryService()
        return res.status(200).json({
            message: "Product Categories fetched successfully",
            product_categories
        })
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product_categories:[]
        })
    }
}

export async function getProductCategory(req:Request, res:Response){
    try{
        const product_category_id: string  = req.params.product_category_id;
        const product_category: IProductCategoryDocument | null = await getProductCategoryService(new Types.ObjectId(product_category_id));
        return res.status(200).json({
            message:"Product category fetched successfully",
            product_category
        })
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product_category:null
        })
    }
}

export async function updateProductCategory(req:Request, res:Response){
    try{
        const product_category_id: string = req.params.product_category_id;
        const input_product_category : IProductCategory = req.body.product_category;
        const updated_product_category: IProductCategoryDocument | null = await updateProductCategoryService(new Types.ObjectId(product_category_id),input_product_category) 
        return res.status(200).json({
            message:"Product Category Update Successfully",
            product_category:updated_product_category
        })
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product_category:null
        })
    }
}

export async function deleteProductCategory(req:Request, res:Response){
    try{
        const product_category_id: string = req.params.product_category_id;
        const deleted_product_category: IProductCategoryDocument | null = await deleteProductCategoryService(new Types.ObjectId(product_category_id));
        return res.status(200).json({
            message: "Product category deleted successfully",
            product_category: deleted_product_category
        })    
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product_category:null
        })
    }
}

Open the next file product.controller.ts and put the following code inside the file

import express, {Request, Response} from 'express';
import { createProductService, listProductsService, getProductService, updateProductService, deleteProductService} from '../services/product.service';
import { IProduct, IProductDocument } from '../models/product.model';
import { Types } from 'mongoose'
export async function createProduct(req:Request, res:Response){
    try{
       const input_product: IProduct = req.body.product
       const saved_product = await createProductService(input_product);
       return res.status(201).json({
        message:"Product Created Successfully",
        product: saved_product
       }) 
       
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product:null
        })
    }
}

export async function listProduct(req:Request, res:Response){
    try{
        const products : IProductDocument[] = await listProductsService()
        return res.status(200).json({
            message: "Products fetched successfully",
            products
        })
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            products:[]
        })
    }
}

export async function getProduct(req:Request, res:Response){
    try{
        const product_id: string  = req.params.product_id;
        const product: IProduct | null = await getProductService(new Types.ObjectId(product_id));
        return res.status(200).json({
            message:"Product fetched successfully",
            product
        })
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product:null
        })
    }
}

export async function updateProduct(req:Request, res:Response){
    try{
        const product_id: string = req.params.product_id;
        const input_product : IProduct = req.body.product;
        const updated_product: IProductDocument | null = await updateProductService(new Types.ObjectId(product_id),input_product) 
        return res.status(200).json({
            message:"Product Update Successfully",
            product:updated_product
        })
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product:null
        })
    }
}

export async function deleteProduct(req:Request, res:Response){
    try{
        const product_id: string = req.params.product_id;
        const deleted_product: IProductDocument | null = await deleteProductService(new Types.ObjectId(product_id));
        return res.status(200).json({
            message: "Product category deleted successfully",
            product: deleted_product
        })    
    }catch(err:any){
        return res.status(500).json({
            message:err.message,
            product:null
        })
    }
}

Step-10 Creating routes and Importing them to app.ts

In this final step of coding we are going to create routes for all the CRUD operation for Product and Product Category. So let’s create a folder routes and create two files ‘product.ts‘ for product and ‘product_category.ts’ for product category

In the file product_category.ts put the following code

import express, { Router } from 'express'
import { createProductCategory, deleteProductCategory, getProductCategory, listProductCategory, updateProductCategory } from '../controllers/product_category.controller';

const routes:Router = express.Router();

// Create Product Category Route
routes.post('/',createProductCategory);
// Get Product Category Route
routes.get('/:product_category_id', getProductCategory);
// List Product Category Route
routes.get('/', listProductCategory);
// Update Product Category Route
routes.put('/:product_category_id',updateProductCategory);
// Delete Product Category Route
routes.delete('/:product_category_id',deleteProductCategory);

export default routes;



and in the file product.ts put the following code

import express, { Router } from 'express'
import { createProduct, listProduct, getProduct, updateProduct, deleteProduct } from '../controllers/product.controller';

const routes:Router = express.Router();

// Create Product Category Route
routes.post('/',createProduct);
// Get Product Category Route
routes.get('/:product_id', getProduct);
// List Product Category Route
routes.get('/', listProduct);
// Update Product Category Route
routes.put('/:product_id',updateProduct);
// Delete Product Category Route
routes.delete('/:product_id',deleteProduct);

export default routes;



Next we need to import and use them inside our app.ts file . Let’s check the final code for app.ts

import express, { Application } from 'express'
import { APPCONFIG } from './config';
import { connect } from './dbconnect';
import productCategoryRoutes from './routes/product_category';
import productRoutes from './routes/product';
const app:Application = express();
const PORT = APPCONFIG.PORT;

connect()

app.use(express.json())

app.use('/api/product_category',productCategoryRoutes);
app.use('/api/product', productRoutes);

app.listen(PORT,()=>{
    console.log(`Server started at PORT ${PORT}`)
})


We have used the app.use to first call the middleware express.json() so that our APIs can accept json input and process them and we have created prefixes for product category and product routes. Other than that there is no change in app.ts

Now it is time to test

Step-11 Testing the APIs

We are going to use POSTMAN to test our APIs . let’s look at some sample run for our product category and product in the below images

Create product Category

Create product

List Product Category

List Product

After reading this article and building the application you will have a fair amount of idea how to write a professional REST API using Node.js Typescript and MongoDB. But still there are few things missing from the scope of this article and a Production grade application those are must do. These must do enhancements to this above application are as follows to make it Production grade application

  1. Use of Logger – If you don’t log errors and information then you are missing an important part of application. Logging helps you to analyze errors and performance of the application and tune and in turn make the application better. There are lots of logging packages are available for Node.js like ‘Winston’, ‘Morgan’ and ‘Pino’ to name a few. If you are going to deploy in cloud and use container technologies like Docker and Kubernetese then these packages will not make sense and then cloud native logging will be necessary like ‘AWS Cloudwatch’ for AWS , ‘Azure Insights’ for Azure are the most prominent ones.
  2. Robust Error Handling : Robust error handling is must for any Production level application. Without proper and meaningful errors your users and fellow coders will not be able to understand what is wrong. In our above application we have done some basic error handling but there are lots of things to cover. We may have done a Class for Error and define different errors for missing properties, validation errors etc. and also could have send the proper http code for the Frontend application like React and Angular to understand the error better and present it in a proper meaningful way to the user.
  3. More advanced Data Access Layer : This one is not so important but still relevant for many application . In many big application now a days use more than one database for doing different tasks. So handling the data access for different databases will require a more robust DAL for the application.

With this we bring this article to an end . Happy Coding.

Previous post All you need to know about setting up MongoDB Atlas for your application: A Visual Guide
Functions in Javascript Next post All you need to know about “Functions in JavaScript”