Skip to main content

MongoDB and GraphQL: A Perfect Match

Learn how to create a GraphQL API with TypeScript and MongoDB, a combination that is not only efficient, but extremely easy to work with.
Feb 18, 2026  · 11 min read

GraphQL is a powerful and efficient way to build APIs. The client queries the API, similarly to how they would a database, and that API returns only the data that they've requested, often reducing the response payload and improving response times. Low response times are critical in the modern world.

When the GraphQL API is paired with MongoDB, you're not only getting those fast response times, but you're also getting a data format that is consistent from start to finish.

Imagine this: Your client is executing a GraphQL query and that query looks similar to JSON. When the data reaches your application—which, let's say, is TypeScript in this example—you're now working with a data format that is similar to JSON in your application. Taking it a step further, when working with MongoDB, the data you send to and from MongoDB will also be similar to JSON. So what you're getting is that consistent data experience on top of performance. No need to worry too much about manipulating and formatting your data, and instead you get to focus on the user experience of your application, not the database and tooling.

In this tutorial, we're going to see just how easy it is to use MongoDB in your GraphQL API, this time built with TypeScript.

The Prerequisites

For this particular tutorial, we'll need to make sure we have a few items ready to go before we begin:

  • A MongoDB Atlas cluster
  • Node.js 22+
  • The MongoDB Node.js Driver 6.x

We won't be doing anything particularly extravagant from a database perspective in this tutorial, so any tiered cluster of MongoDB will work, even the free tier. However, we won't be exploring the provisioning of a MongoDB Atlas cluster in this tutorial. The expectation is that you have user rules and network rules ready to go. If you need help getting going, check out the Get Started With Atlas documentation on the subject.

You'll need to have a Node.js application created. If you'd like, you can execute the following commands:

mkdir graphql_example
cd graphql_example
npm init -y

The above commands create a new project directory and a basic package.json file. We'll be using TypeScript, Express Framework, MongoDB, and Apollo in this project. They can each be installed with the following commands:

npm install @apollo/server@4 body-parser cors dotenv express@4 graphql mongodb
npm install @types/cors @types/express@4 @types/node tsx typescript --save-dev

The above commands will install the dependencies as well as the type definitions to be used in TypeScript.

As the final part of our prerequisites, you'll need to have a configuration file for TypeScript. At the root of your project, create a tsconfig.json file with the following JSON:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

If at any point you'd like to view the project as a whole or sample it for yourself, you can check it out on GitHub.

Create the Foundation With Apollo, Express, and MongoDB Dependencies

From a project structure perspective, we'll making use of the following:

  • src/database/connection.ts
  • src/database/userService.ts
  • src/resolvers/index.ts
  • src/schema/typeDefs.ts
  • src/types/index.ts
  • src/index.ts
  • .env

The src/index.ts file will contain all of our Express Framework and Apollo bootstrapping. This means starting the server, defining the GraphQL endpoints, etc. The src/types/index.ts file will contain our TypeScript definitions for working with our database data model within our application. This isn't to be confused with the src/schema/typeDefs.ts file which, although similar, focuses on the GraphQL type definitions.

This leaves us with our database and resolvers directories of files. Most of our application and database logic will be in these files, something we'll explore soon.

For now, let's focus on getting our server setup in the src/index.ts file:

import 'dotenv/config';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import http from 'http';
import cors from 'cors';
import bodyParser from 'body-parser';
import { typeDefs } from './schema/typeDefs';
import { resolvers } from './resolvers';

async function startServer() {
  try {

    const app = express();
    
    const httpServer = http.createServer(app);

    const server = new ApolloServer({
      typeDefs,
      resolvers,
    });

    await server.start();

    app.use(
      '/graphql',
      cors<cors.CorsRequest>(),
      bodyParser.json(),
      expressMiddleware(server)
    );

    const PORT = process.env.PORT || 3000;
    await new Promise<void>((resolve) => httpServer.listen({ port: PORT }, resolve));
    
    console.log(Server ready at http://localhost:${PORT}/graphql);

  } catch (error) {
    console.error('Error starting server:', error);
    process.exit(1);
  }
}

startServer();

We'll be expanding upon the above file as we progress.

To start, we are importing all of our project dependencies. Next, we are configuring our Express Framework server and our Apollo middleware.

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

We haven't added anything to our src/schema/typeDefs.ts file and src/resolvers/index.ts file yet, but as part of this stage, we're getting everything ready. We won't be successful in running the server without errors until we start filling in the gaps of the other files.

It’s worth mentioning that this particular example is allowing requests from all origins because of the following:

cors<cors.CorsRequest>(),

While this is good for a development environment, consider adding only the origins you need in a production deployment.

Interacting With MongoDB From the TypeScript With Node.js Application

While we have the Express Framework and Apollo server foundation in place, we haven't configured our MongoDB database within the application. Before we jump into the code, we need to add the connection information to our .env file:

MONGODB_URI="mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/?appName=devrel-graphql"
DB_NAME="graphql_example"

The <USERNAME>, <PASSWORD>, and <HOST> should be replaced with your MongoDB Atlas cluster information found within your MongoDB Atlas dashboard.

Next, let's establish the database connection logic for our application. Within the **src/database/connection.ts** file, add the following code:

import { MongoClient, Db } from 'mongodb';

let db: Db | null = null;
let client: MongoClient | null = null;

export async function connectToDatabase(): Promise<Db> {
  if (db) {
    return db;
  }

  try {
    const MONGODB_URI = process.env.MONGODB_URI;
    if (!MONGODB_URI) {
      throw new Error('MONGODB_URI is not set');
    }
    const DB_NAME = process.env.DB_NAME || 'graphql_example';
    const APP_NAME = process.env.APP_NAME || 'devrel-graphql';

    console.log('Connecting to MongoDB...');
    
    client = new MongoClient(MONGODB_URI, { appName: APP_NAME });
    await client.connect();
    
    db = client.db(DB_NAME);
    
    console.log(Successfully connected to MongoDB database: ${DB_NAME});
    
    return db;
  } catch (error) {
    console.error('MongoDB connection error:', error);
    throw error;
  }
}

export function getDatabase(): Db {
  if (!db) {
    throw new Error('Database not initialized. Call connectToDatabase() first.');
  }
  return db;
}

export async function closeDatabase(): Promise<void> {
  if (client) {
    await client.close();
    db = null;
    client = null;
    console.log('MongoDB connection closed');
  }
}

The idea here is that we're creating a singleton connection. There are plenty of other methods to getting MongoDB working within your application, but this is the route we're choosing for this particular application.

The connectToDatabase function will take the connection information from the **.env** file, connect, and return the database that you've defined. If it fails, the error will be caught and thrown. You should only ever need to use the connectToDatabase function when setting up your server like seen in the previous file.

The getDatabase function will be used any time a request is made to our application. This will allow us to get the current instance of our connection.

Finally, if we want to keep things clean, we have a closeDatabase function for closing the connection to our database when our server terminates. It's up to you if you want to include a function like this, but it's good practice to have it.

Before we get to our database logic, it's probably a good idea to get our TypeScript types for our data transfer objects (DTOs):

export interface User {
  id: string;
  name: string;
  email: string;
  age?: number;
  createdAt: string;
}

export interface CreateUserInput {
  name: string;
  email: string;
  age?: number;
}

export interface UpdateUserInput {
  id: string;
  name?: string;
  email?: string;
  age?: number;
}

The above interfaces, found in the **src/types/index.ts** file, are managed by our API, but our database logic still needs to be aware of them. You'll see in just a moment.

This brings us to our actual database interactions for create, retrieve, update, delete (CRUD), and this is done in the src/database/userService.ts file. The idea here is that our GraphQL resolvers will handle application logic and call the functions in this service file, and the service will handle the database logic.

In the src/database/userService.ts file, we have the following code:

import { Collection, ObjectId } from 'mongodb';
import { getDatabase } from './connection';
import { User, CreateUserInput, UpdateUserInput } from '../types';

interface UserDocument {
  _id: ObjectId;
  name: string;
  email: string;
  age?: number;
  createdAt: Date;
}

export class UserService {
  private collection: Collection<UserDocument>;

  constructor() {
    const db = getDatabase();
    this.collection = db.collection<UserDocument>('users');
  }

  private toUser(doc: UserDocument): User {
    return {
      id: doc._id.toString(),
      name: doc.name,
      email: doc.email,
      age: doc.age,
      createdAt: doc.createdAt.toISOString(),
    };
  }

  async getAllUsers(): Promise<User[]> {
    const users = await this.collection.find().toArray();
    return users.map(doc => this.toUser(doc));
  }

  async getUserById(id: string): Promise<User | null> {
    try {
      const doc = await this.collection.findOne({ _id: new ObjectId(id) });
      return doc ? this.toUser(doc) : null;
    } catch (error) {
      return null;
    }
  }

  async createUser(input: CreateUserInput): Promise<User> {
    const newUser: Omit<UserDocument, '_id'> = {
      name: input.name,
      email: input.email,
      age: input.age,
      createdAt: new Date(),
    };

    const result = await this.collection.insertOne(newUser as UserDocument);
    
    const createdUser = await this.collection.findOne({ _id: result.insertedId });
    
    if (!createdUser) {
      throw new Error('Failed to create user');
    }

    return this.toUser(createdUser);
  }

  async updateUser(input: UpdateUserInput): Promise<User | null> {
    try {
      const updateFields: Partial<Omit<UserDocument, '_id'>> = {};
      
      if (input.name !== undefined) updateFields.name = input.name;
      if (input.email !== undefined) updateFields.email = input.email;
      if (input.age !== undefined) updateFields.age = input.age;

      const result = await this.collection.findOneAndUpdate(
        { _id: new ObjectId(input.id) },
        { $set: updateFields },
        { returnDocument: 'after' }
      );

      return result ? this.toUser(result) : null;
    } catch (error) {
      return null;
    }
  }

  async deleteUser(id: string): Promise<boolean> {
    try {
      const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
      return result.deletedCount > 0;
    } catch (error) {
      return false;
    }
  }

  async createIndexes(): Promise<void> {
    await this.collection.createIndex({ email: 1 }, { unique: true });
    await this.collection.createIndex({ createdAt: -1 });
    console.log('Database indexes created');
  }
}

The first thing you'll notice above is the following interface:

interface UserDocument {
  _id: ObjectId;
  name: string;
  email: string;
  age?: number;
  createdAt: Date;
}

It looks similar to our User interface, but it's not quite the same. The UserDocument is more strictly modeled after what our database works with. We're working with ObjectId in our database, but string hashes in our application. In MongoDB, the id is represented as _id instead of id, like what we see in the User interface.

This brings us into the UserService class.

After obtaining a handle to our collection from the database instance through the constructor method, we have the following series of functions:

  • toUser
  • getAllUsers
  • getUserById
  • createUser
  • updateUser
  • deleteUser
  • createIndexes

The toUser function is essentially a helper function that converts between User and UserDocument. We send and return UserDocument to and from the database and we need to convert it so we can send and receive User from the application.

async createIndexes(): Promise<void> {
	await this.collection.createIndex({ email: 1 }, { unique: true });
	await this.collection.createIndex({ createdAt: -1 });
	console.log('Database indexes created');
}

The createIndexes function will create some example indexes that might be valuable to us. By default, an index will only exist on the _id field, which is fine for a demo that has just a small handful of documents, but as your application grows, don't ignore proper index creation based on your data and query needs. The above two indexes are only examples of what you might want.

Now, we can look at each service function starting with the getAllUsers function:

async getAllUsers(): Promise<User[]> {
	const users = await this.collection.find().toArray();
	return users.map(doc => this.toUser(doc));
}

The above function will find all documents in the collection because there is no filter criteria in the find function. The results are converted with our toUser function and returned to the application, which will later be our Apollo resolvers.

async getUserById(id: string): Promise<User | null> {
	try {
	  const doc = await this.collection.findOne({ _id: new ObjectId(id) });
	  return doc ? this.toUser(doc) : null;
	} catch (error) {
	  return null;
	}
}

The getByUserId function is similar, but this time, we're using findOne and specifying a filter criteria—the criteria here being that we want a single user based on the unique _id field. Because the id field in the User interface is a string, we need to wrap it in the ObjectId object to make it suitable for our queries within MongoDB.

This brings us to creating data within MongoDB via the createUser function:

async createUser(input: CreateUserInput): Promise<User> {
	const newUser: Omit<UserDocument, '_id'> = {
	  name: input.name,
	  email: input.email,
	  age: input.age,
	  createdAt: new Date(),
	};
	
	const result = await this.collection.insertOne(newUser as UserDocument);
	
	const createdUser = await this.collection.findOne({ _id: result.insertedId });
	
	if (!createdUser) {
	  throw new Error('Failed to create user');
	}
	
	return this.toUser(createdUser);
}

After using the insertOne function to insert our data, we use the returned insertedId to then query that data. We do this because in a GraphQL API, when data is created, the general expectation is that you can query for that data in the same mutation.

The updateUser function is a little different:

async updateUser(input: UpdateUserInput): Promise<User | null> {
	try {
	  const updateFields: Partial<Omit<UserDocument, '_id'>> = {};
	  
	  if (input.name !== undefined) updateFields.name = input.name;
	  if (input.email !== undefined) updateFields.email = input.email;
	  if (input.age !== undefined) updateFields.age = input.age;
	
	  const result = await this.collection.findOneAndUpdate(
		{ _id: new ObjectId(input.id) },
		{ $set: updateFields },
		{ returnDocument: 'after' }
	  );
	
	  return result ? this.toUser(result) : null;
	} catch (error) {
	  return null;
	}
}

In this particular application, we're only allowing for updates against the name, email, and age fields of our data. All of these fields are optional, so we have to set them in our update criteria if they are present. 

This leads us to the findOneAndUpdate function.

The first object parameter of this function is the filter criteria. We're only interested in doing an update where the _id is a match. For any match, we use the $set operator to replace or create any fields in our updateFields object. Fields that don't show up in the object won't be changed. Finally, since the expectation is that we're finding and updating rather than just updating, we set returnDocument to after because we want the changed document as the response, not the original document. Had we done an updateOne instead of a findOneAndUpdate, we would have only received information about the update operation, not the data.

The final CRUD operation for this API is for removing data from MongoDB, and this is done through the deleteUser function:

async deleteUser(id: string): Promise<boolean> {
	try {
	  const result = await this.collection.deleteOne({ _id: new ObjectId(id) });
	  return result.deletedCount > 0;
	} catch (error) {
	  return false;
	}
}

The above deleteOne function looks like our findOne because we're just using a filter criteria based on the _id. The difference is that any match—in this case, a maximum of one document—will be deleted.

At this point, the database logic is good to go. To finalize it, we need to connect from the **src/index.ts** file:

// Previous imports here...
import { connectToDatabase, closeDatabase } from './database/connection';
import { UserService } from './database/userService';

async function startServer() {
  try {

    await connectToDatabase();
    
    const userService = new UserService();
    await userService.createIndexes();
    
    // The rest of the file here...
}

In the above snippet, most of the src/index.ts file was omitted. The goal was just to show the use of the connectToDatabase function and the creation of the indexes. To be clear, just drop those three lines toward the top of your startServer function.

At this point, we can focus on the API logic.

Define GraphQL Types, Resolvers, and Custom Application Logic

We already have the TypeScript types defined for working with our database as well as passing around data through our application. However, we don't have the GraphQL type definitions created, something that defines what the user can interact with when using our API.

In the src/schema/typeDefs.ts file, add the following:

export const typeDefs = #graphql
  type User {
    id: ID!
    name: String!
    email: String!
    age: Int
    createdAt: String!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    createUser(name: String!, email: String!, age: Int): User!
    updateUser(id: ID!, name: String, email: String, age: Int): User
    deleteUser(id: ID!): Boolean!
  }
;

The User type is what the client is allowed to request in any given query. In this example, it matches the User interface, but it doesn't need to. For example, maybe your User interface contains data that you don't ever want returned back to the client, such as a password.

The Query type defines the types of queries that can be executed within our API. Each query will return a User or an array of User, but we can have specific logic in these queries in-between.

The Mutation type is similar to the Query, but these are meant to be queries that manipulate data.

With the GraphQL type definitions in place, we can focus on the resolver logic for the application. In the project's src/resolvers/index.ts file, include the following code:

import { UserService } from '../database/userService';
import { User, CreateUserInput, UpdateUserInput } from '../types';

export const resolvers = {
  Query: {
    users: async (): Promise<User[]> => {
      const userService = new UserService();
      return await userService.getAllUsers();
    },

    user: async (_: unknown, { id }: { id: string }): Promise<User | null> => {
      const userService = new UserService();
      return await userService.getUserById(id);
    },
  },

  Mutation: {
    createUser: async (
      _: unknown,
      { name, email, age }: CreateUserInput
    ): Promise<User> => {
      const userService = new UserService();
      return await userService.createUser({ name, email, age });
    },

    updateUser: async (
      _: unknown,
      { id, name, email, age }: UpdateUserInput
    ): Promise<User | null> => {
      const userService = new UserService();
      return await userService.updateUser({ id, name, email, age });
    },

    deleteUser: async (_: unknown, { id }: { id: string }): Promise<boolean> => {
      const userService = new UserService();
      return await userService.deleteUser(id);
    },
  },
};

The resolvers above are just an extension to what we saw in the type definitions file for queries and mutations.

Take a look at the users query:

users: async (): Promise<User[]> => {
  const userService = new UserService();
  return await userService.getAllUsers();
},

This GraphQL query will make use of our UserService, part of the database logic, to get all users. The User[] response will get sent back to the user, but only the bits and pieces that the user requested that match the GraphQL type definitions.

So for example, let's say the client sent this query:

query {
	users {
		name
		email
	}
}

In the above example, we have more fields to work with, but the client only wants the name and email fields. This was handled based on our GraphQL type definitions that the client is working with. Apollo handles the magic to make it happen.

The same rules are going to apply for each query and mutation within our **src/resolvers/index.ts** file.

createUser: async (
  _: unknown,
  { name, email, age }: CreateUserInput
): Promise<User> => {
  const userService = new UserService();
  return await userService.createUser({ name, email, age });
},

For the createUser type definition, we saw the client can pass the name, email, and age fields. In the createUser resolver function, we are doing the same and we are calling the createUser function from our database service. The response is returned to the client based on whatever fields they've requested.

Build and Run the GraphQL API

At this point, the GraphQL API should be in great shape. There are a few ways that you can build and run this application.

For example, you could add the following to your **package.json** file:

"scripts": {
	"build": "tsc",
	"start": "node dist/index.js",
	"dev": "tsx src/index.ts",
	"dev:watch": "tsx watch src/index.ts",
	"watch": "tsc -w"
},

If you've chosen this route, you can run npm run build to build the project and npm run start to start serving the project. The default port is 3000, and it can be tested with cURL or any tool of your preference.

Conclusion

You just saw how to create a GraphQL API with TypeScript and MongoDB as the database. While this example was meant to be simplistic, we saw how to build queries and mutations for doing CRUD operations against our database. MongoDB is a perfect match when used in a GraphQL API because the data that you're working with is formatted similarly from start to finish. For example, the client runs a query that looks similar to JSON. The data passed through the query can be used with minimal formatting and adjustments, and likewise when it is sent to and from the database. The experience is smooth, allowing you to focus on user experience and less on manipulating your data to satisfy the database.

If you got stuck at any point in this tutorial, make sure you check out the finished project on GitHub.

FAQs

Why should I use GraphQL?

GraphQL is great because you can query against an API like you would a database, asking for only what you need in return. This makes things lightweight and efficient for the client.

Is Apollo the only option for GraphQL?

Apollo makes creating GraphQL APIs very easy, but it isn’t the only option. However, the other options are out of the scope of the tutorial.

Why use MongoDB with GraphQL?

Both work with JSON-like data formats, so you don't need to constantly convert between different data structures as information flows from database to API to client.

What does a resolver do?

A resolver is a function that tells GraphQL how to fetch or modify data for a specific field in your schema.

What's the difference between a query and a mutation?

Queries are for reading/fetching data (like GET requests), while mutations are for creating, updating, or deleting data (like POST, PUT, DELETE).


Nic Raboy's photo
Author
Nic Raboy

Nic Raboy is a Developer Relations Lead at MongoDB where he leads a team of Python, Java, C#, and PHP developers who create awesome content to help developers be successful at including MongoDB in their projects. He has experience with Golang and JavaScript and often writes about many of his development adventures.

Topics

Top DataCamp Courses

Course

Introduction to MongoDB in Python

3 hr
23.1K
Learn to manipulate and analyze flexibly structured data with MongoDB.
See DetailsRight Arrow
Start Course
See MoreRight Arrow
Related

blog

MySQL vs MongoDB: Choosing the Right Database for Your Project

Learn the pros, cons, and real-world examples of MySQL vs MongoDB. Compare schema design, performance & scaling to choose the best database for your project.
Mark Pedigo's photo

Mark Pedigo

13 min

Tutorial

Getting Started with MongoDB Query API

Master the MongoDB Query API with this comprehensive guide to CRUD operations, advanced filters, data aggregation, and performance-boosting indexing.
Karen Zhang's photo

Karen Zhang

Tutorial

MongoDB Indexing Best Practices: Performance Tips & Tricks

Learn about how to create MongoDB indexes and some tips and tricks to get the best performance out of them.
Nic Raboy's photo

Nic Raboy

Tutorial

How to Integrate Apache Spark With Django and MongoDB

Build a complete data pipeline connecting Django, MongoDB Atlas, and Apache Spark for e-commerce analytics.
Damilola Oladele's photo

Damilola Oladele

Tutorial

Introduction to MongoDB and Python

In this tutorial, you'll learn how to integrate MongoDB with your Python applications.
Derrick Mwiti's photo

Derrick Mwiti

Tutorial

MongoDB Schema Validation: A Practical Guide with Examples

This guide teaches you how to enforce clean and consistent data in MongoDB using schema validation, balancing flexibility with structure.
Samuel Molling's photo

Samuel Molling

See MoreSee More