How to Easily Build a Web API with Express and MongoDB [TypeScript Compatible]

  • mongodb
    mongodb
  • expressjs
    expressjs
  • typescript
    typescript
  • mongoose
    mongoose
Published on 2024/12/09

Introduction

In recent years, web application development that leverages cloud databases and type-safe programming languages has been attracting attention. In particular, MongoDB Atlas makes it easy to manage databases in the cloud and is well-suited for building scalable applications. TypeScript is also widely adopted by developers because it improves code readability and maintainability.

In this article, we will explain concretely how to build a REST API by combining these technologies. We will proceed in a beginner-friendly, step-by-step format, from setting up MongoDB Atlas and routing with Express, to implementing type-safe logic using TypeScript. By reading this article, you will gain a basic understanding of API development using MongoDB Atlas and experience the benefits of type-safe development practices.

For those who are not yet very familiar with how Express works, we have prepared an article that explains the basic usage of Express.

https://shinagawa-web.com/en/blogs/express-typescript-setup-guide

Goal for This Tutorial

We will use MondbDB as the database, access it from Express, and store the results in the database.

Image from Gyazo

In the end, we will access the Express server using the four methods GET, POST, PATCH, and DELETE to retrieve, create, update, and delete data.

Requests to the server will be made using the curl command.

Image of retrieving data with the GET method:

Image from Gyazo

You can also check whether the data has been registered on MongoDB Atlas.

Image from Gyazo

What is MongoDB?

MongoDB is a document-oriented NoSQL database and a database management system that excels in flexible data models and scalability. It has the following characteristics:

Main Features

  1. Document-oriented
    Data is stored as “BSON (Binary JSON)”, which is similar to JSON format. Instead of tables and rows (RDBMS), it uses the concepts of collections and documents.

  2. Schemaless
    No explicit schema definition is required, and the data structure can be changed flexibly. It is robust against changing project requirements and scaling up.

  3. High scalability
    Horizontal scaling is easy through sharding (splitting data). It can efficiently handle large volumes of data.

  4. High performance
    High-speed read/write performance. Especially suitable for real-time data analysis and applications where the data structure changes frequently.

  5. Rich ecosystem
    Provides drivers for major programming languages such as Node.js, Python, and Java. By using MongoDB Atlas (cloud service), you can use a database without building infrastructure.

What is MongoDB Atlas?

MongoDB Atlas is a cloud-based database service provided by MongoDB Inc. It is a fully managed database platform that takes care of time-consuming server setup and operations, and can be used on the following cloud providers:

  • AWS (Amazon Web Services)
  • GCP (Google Cloud Platform)
  • Microsoft Azure

You could also choose to set up MongoDB by running Docker in your local environment, but since MongoDB Atlas is easier to get started with, we will use MongoDB Atlas this time.

MongoDB Atlas is free to use as long as storage is within 512MB.

Setting up mongoDB Atlas

Access the following and register an account.

https://www.mongodb.com/cloud/atlas/register

Click New Project to go to the new project creation screen.

Image from Gyazo

Set the project name to express-tutorial.

Image from Gyazo

Click Create Project to create the project.

Image from Gyazo

Once the project has been created, the next step is to create a cluster.

Image from Gyazo

You can select the cluster specs.

This time, select M0, which is marked as Free, and create the cluster.

Image from Gyazo

Configure the IP address and user that will connect to the cluster.

For the IP address, current IP Address should be fine for now. You will need to change this setting when you deploy Express somewhere.

Then set the username and password.

Image from Gyazo

Once the configuration is complete, you will see Choose a connection method.

Image from Gyazo

Select Drivers.

Image from Gyazo

Select Mongoose.

Image from Gyazo

You will need to set this in an environment variable, so make a note of it.

mongodb+srv://dbUser:<db_password>@cluster0.elo46.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0

Project Setup

Create a new project for Express and install the required packages.

mkdir express-mongodb-rest-api-development-with-typescript
cd express-mongodb-rest-api-development-with-typescript
npm init -y

Installing packages

npm i express cors dotenv express-async-handler mongoose
npm i -D @types/node @types/express @types/mongoose @types/cors rimraf @types/rimraf ts-node-dev typescript

express

  • Purpose: Web framework for Node.js.
  • Role:
    • Routing (branching the handling of HTTP requests).
    • Handling requests/responses using middleware.
    • Useful for building REST APIs and web applications.

@types/express

  • Purpose: Type definition file for Express.
  • Role:
    • Enables type-safe use of Express in TypeScript.
    • Clarifies the types of arguments and return values for routes and middleware.

cors

  • Purpose: Middleware for configuring Cross-Origin Resource Sharing (CORS).
  • Role:
    • Allows requests from other origins (domains).
    • Enables secure integration between frontend and backend.

@types/cors

  • Purpose: Type definition file for CORS.
  • Role:
    • Enables type-safe use of the CORS middleware in TypeScript.
    • Makes configuration option types explicit.

dotenv

  • Purpose: Package that loads environment variables from a .env file.
  • Role:
    • Safely manages environment variables (API keys, database URLs, etc.).
    • Simplifies switching settings between environments.

express-async-handler

  • Purpose: Utility for easily handling asynchronous functions in Express.
  • Role:
    • Simplifies error handling in asynchronous functions.
    • Passes errors to the error handler without writing try-catch.

mongoose

  • Purpose: Object Data Modeling (ODM) library for MongoDB.
  • Role:
    • Defines schemas and models.
    • Simplifies queries to MongoDB.
    • Provides mapping between documents and JavaScript objects.

@types/mongoose

  • Purpose: Type definition file for Mongoose.
  • Role:
    • Enables type-safe use of Mongoose in TypeScript.
    • Strengthens type definitions for models and schemas.

@types/node

  • Purpose: Type definition file for Node.js.
  • Role:
    • Enables type-safe use of Node.js built-in modules (fs, http, path, etc.) in TypeScript.

rimraf

  • Purpose: Utility for deleting folders and files.
  • Role:
    • Provides rm -rf-like operations in a cross-platform way.

@types/rimraf

  • Purpose: Type definition file for rimraf.
  • Role:
    • Enables type-safe use of rimraf in TypeScript.

ts-node

  • Purpose: Tool for running TypeScript code directly.
  • Role:
    • Executes TypeScript scripts without transpiling.
    • Convenient during development.

ts-node-dev

  • Purpose: Tool for hot reloading TypeScript code.
  • Role:
    • Automatically restarts the server when code changes.
    • Improves development efficiency.

Creating tsconfig

Create a tsconfig.json file to manage the compiler settings for the TypeScript project.

npx tsc --init

Creating .env

Manage the MongoDB connection information obtained earlier in .env.

MONGO_URI=mongodb+srv://dbUser:dbUserPassword@cluster0.elo46.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0

Connecting to MongoDB from Express

MongoDB connection logic

Create the src/lib/db.ts file and write the code.

src/lib/db.ts
import mongoose from 'mongoose';
import dotenv from 'dotenv';

dotenv.config();

const DATABASE_URL = process.env.MONGO_URI!

const connectDB = async () => {
  try {
    const connection = await mongoose.connect(DATABASE_URL);
    console.log(`MongoDB Connected ${connection.connection.host}`);
  } catch (error) {
    if (error instanceof Error) {
      console.error(`Error: ${error.message}`);
    } else {
      console.error(`Unexpected Error: ${error}`);
    }
    process.exit(1);
  }
};

export default connectDB;

Creating the Express entry point

src/index.ts
import express from 'express';
import cors from 'cors';
import connectDB from './lib/db';

connectDB();

const app = express();

app.use(cors());

app.use(express.json());
app.use(express.urlencoded({ extended: true }));


app.listen(3001);
console.log('Express WebAPI listening on port ' + port);

Configuring package.json

Configure the command to start the Express server in package.json.

package.json
  "scripts": {
+   "dev": "ts-node-dev --respawn src/index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Operation check

Run the following command to start the Express server and connect to MongoDB.

npm run dev

If you see a message like the one below, both have succeeded.

Image from Gyazo

Creating the Model

We will define the data to be stored in MongoDB.
This time, assuming we are creating a TODO app, we will prepare the following fields:

  • Title: String
  • Completion flag: Boolean (default: false)
  • Created date: Date (default: registration time)
  • Updated date: Date
src/models/todo.ts
import mongoose from 'mongoose';

const PostSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: true,
    },
    isCompleted: {
      type: Boolean,
      required: true,
      default: false,
    },
    createdAt: {
      type: Date,
      required: true,
      default: Date.now,
    },
    updatedAt: {
      type: Date,
    },
  },
);

const Post = mongoose.model('Post', PostSchema);

export default Post;

Registering Data

Using the model we just created, we will register initial data from the local environment to MongoDB.

Creating data for registration

Create two todos.

We omit isCompleted and createdAt here because default values are set for them.

src/seeds/todos.ts
export const todos = [
  {
    title: 'Reply to inquiries',
  },
  {
    title: 'Write a blog post',
  },
]

Creating the registration script

To make the registration process repeatable, we first delete all records and then insert them.

We also prepare a process for deleting all records in advance.

src/seeds/seed.ts
import dotenv from 'dotenv';
import { todos } from './todo';
import Todo from '../models/todo';
import connectDB from '../lib/db';

dotenv.config();

connectDB();

const importData = async () => {
  try {
    // First delete todos, then insert all
    await Todo.deleteMany();
    const createPosts = await Todo.insertMany(todos);

    console.log(`Data Imported!`);
    process.exit();
  } catch (error) {
    console.error(`Error: ${error}`);
    process.exit(1);
  }
};

const destroyData = async () => {
  try {
    await Todo.deleteMany();

    console.log(`Data Destroyed!`);
    process.exit();
  } catch (error) {
    console.error(`Error: ${error}`);
    process.exit(1);
  }
};

// If the -d parameter is passed on the command line, run in delete mode
if (process.argv[2] === '-d') {
  destroyData();
} else {
  importData();
}

Registering the data

Run the following command to register the data.

npx ts-node src/seeds/seed.ts

If Data Imported! is displayed, registration is complete.

Image from Gyazo

You can also confirm that the data has been registered on MongoDB Atlas.

Image from Gyazo

Now that the preparations are complete, we will move on to the implementation.

Retrieving Data (GET method)

First, we will implement a GET method to retrieve the registered data.

Creating the controller

Create the DB access logic in the controller.

src/controllers/todo.ts
import {Request, Response} from 'express';
import asyncHandler from 'express-async-handler';
import Todo from '../models/todo';

export const getPosts = asyncHandler(async (req: Request, res: Response) => {
  const todos = await Todo.find();
  res.json(todos);
});

Creating the routing

Define that when a GET request is received, the getTodos we just created will be called.
We will also group routes because we will create other routes such as POST methods later.

src/routes/todo.ts
import express from 'express';
import { getTodos } from '../controllers/todo';

const router = express.Router();

router.route('/').get(getTodos)

export default router

Applying the routing to the entry point

Reflect the above in the entry point.
In this TODO app, all URLs will be set under /todos.

src/index.ts
import express from 'express';
import cors from 'cors';
import connectDB from './lib/db';
+ import todoRoutes from './routes/todo'

connectDB();

const app = express();

app.use(cors());

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

+ app.use('/todos', todoRoutes)

app.listen(3001);
console.log('Express WebAPI listening on port 3001');

Express routing is explained in detail in the following article.

https://shinagawa-web.com/en/blogs/express-typescript-setup-guide#ルーティング

Operation check

Actually access the API and check whether the data can be retrieved.
We use jq to make the output easier to read.

curl -s http://localhost:3001/todos | jq

If the two todos registered in advance are output, it is OK.

Image from Gyazo

Creating New Data (POST method)

The implementation is almost the same as the GET method.

src/controllers/todo.ts
export const addTodo = asyncHandler(async (req: express.Request, res: express.Response) => {
  const newPost = new Post(req.body as PostType);

  await newPost.save((error, post) => {
    if (error) res.send(error);
    res.json(post);
  });
});

The access URL for the POST method will be the same as for the GET method.

src/routes/todo.ts
- router.route('/').get(getTodos)
+ router.route('/').get(getTodos).post(addTodo)

Operation check

Send a request with the POST method.

curl -X POST -H "Content-Type: application/json" -d '{"title": "Post test"}' http://localhost:3001/todos

If registration is successful, the registered content will be returned.

Image from Gyazo

Updating Data (PATCH method)

We search for the target data based on the received todoId and update it.
If the id does not exist and cannot be updated, we return a message indicating that.

src/controllers/todo.ts
export const updateTodo = asyncHandler(async (req: Request, res: Response): Promise<void> => {
  const todo = await Todo.findByIdAndUpdate(
    { _id: req.params.todoId },
    req.body,
    { new: true }
  );

  if (!todo) {
    res.status(404).json({ message: "Todo not found" });
  }

  res.json(todo)
});

Define routing so that it can receive todoId.

src/routes/todo.ts
router.route('/:todoId').patch(updateTodo)

Operation check

This time, we will mark an existing Todo as completed.

We will use this Todo as the update target:

[
  {
    "_id": "678064b4923d0c1c0e9ebea6",
    "title": "Reply to inquiries",
    "isCompleted": false,
    "createdAt": "2025-01-10T00:07:16.292Z",
    "__v": 0
  },

Set {"isCompleted": true} in the body of the PATCH method.
Set the ID of the update target in the URL.

curl -X PATCH -H "Content-Type: application/json" -d '{"isCompleted": true}' http://localhost:3001/todos/678064b4923d0c1c0e9ebea6

You can confirm in the update result that isCompleted: true.

Image from Gyazo

If you change the id to 678064b4923d0c1c0e9ebea5, changing only the last digit so that it becomes a non-existent id, and send the request, you can confirm that the message you set is returned.

Image from Gyazo

Deleting Data (DELETE method)

We search for the target data based on the received todoId and delete it.
If the id does not exist and cannot be deleted, we return a message indicating that.

src/controllers/todo.ts
export const deleteTodo = asyncHandler(async (req: Request, res: Response): Promise<void> => {

  const todo = await Todo.findByIdAndDelete(req.params.todoId);

  if (!todo) {
    res.status(404).json({ message: "Todo not found" });
  }

  res.json({ message: "Todo deleted successfully", todo });
});

Define routing for the DELETE method so that it can also receive todoId.

src/routes/todo.ts
- router.route('/:todoId').patch(updateTodo)
+ router.route('/:todoId').patch(updateTodo).delete(deleteTodo)

Operation check

We will delete this data:

  {
    "_id": "6780847655833897d0f2f103",
    "title": "Post test",
    "isCompleted": false,
    "createdAt": "2025-01-10T02:22:46.525Z",
    "__v": 0
  }
]

Send a DELETE request with the following command:

curl -X DELETE http://localhost:3001/todos/6780847655833897d0f2f103

You should be able to confirm that it has been successfully deleted.

Image from Gyazo

If you run it once and then send the same request again, the id will already be non-existent, so a message indicating that it cannot be deleted will be displayed.

Image from Gyazo

Conclusion

In this article, we explained how to build a REST API using MongoDB Atlas, from initial setup to implementation, as well as key points for type-safe development. Through this, you should now have a basic understanding of how to use MongoDB Atlas and TypeScript.

When you further extend your application, you can create more practical and reliable APIs by considering authentication features, advanced error handling, and preparing deployment environments. In addition, by strengthening TypeScript’s typing, you can improve maintainability in team development and long-term projects.

We hope your projects using MongoDB Atlas and TypeScript will be successful.

Recommended Article

This article explains how to efficiently test Express application APIs by combining Supertest and Jest.
It covers introducing a service layer, refactoring toward testable code design, and implementing tests for GET, POST, PATCH, and DELETE methods, with detailed explanations and concrete code examples.

https://shinagawa-web.com/en/blogs/supertest-jest-express-mongodb-end-to-end-testing

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form