Getting Started With Deno 2.0 & MongoDB
Jesse Hall13 min read • Published Jan 21, 2022 • Updated Oct 22, 2024
FULL APPLICATION
Rate this tutorial
Deno, the "modern" runtime for JavaScript and TypeScript built in Rust, has recently released version 2.0. This major update brings significant improvements and new features that make Deno an even more attractive option for developers.
If you're familiar with Node.js, you'll find Deno quite similar but with some key improvements. From the same creator as Node.js, Ryan Dahl, Deno is designed to be a more secure and modern successor to Node.js.
Fun fact: Deno is an anagram. Rearrange the letters in Node to spell Deno.
Deno now supports package managers like npm and JSR, while still maintaining the ability to import directly from URLs without a package manager. It uses ES modules, has first-class
await
support, has built-in testing, and implements web standard APIs where possible, such as built-in fetch
and localStorage
. This flexibility in dependency management allows developers to choose the approach that best suits their project needs.Aside from that, it's also very secure. It's completely locked down by default and requires you to enable each access method specifically. This makes Deno pair nicely with MongoDB since it is also super secure by default.
Learn by watching
Here is a video version of this article if you prefer to watch.
Deno 2.0 introduces several exciting features and improvements:
- Enhanced npm compatibility: Deno now supports a wider range of npm packages, including the official MongoDB driver.
- Improved performance: Significant speed improvements in various operations.
- Built-in test runner: No need for external testing frameworks.
- Native HTTP server: Build simple web applications without third-party frameworks.
- Enhanced security model: More granular permissions and improved security features.
In this tutorial, we'll explore some of these new features while building a simple CRUD application using MongoDB.
- Basic TypeScript knowledge
- Understanding of MongoDB concepts
- Familiarity with RESTful APIs
To get started with Deno 2.0, you'll need to install or update Deno on your system.
- For macOS and Linux:
curl -fsSL https://deno.land/install.sh | sh
- For Windows (using PowerShell):
irm https://deno.land/install.ps1 | iex
If you are using VS Code, I highly recommend installing the official Denoland extension. This extension enables type checking, IntelliSense, Deno CLI integration, and much more.
With Deno 2.0, we can create a simple HTTP server with routing capabilities using only built-in features. Let's start by creating a
server.ts
file:1 const PORT = 3000; 2 3 async function handler(req: Request): Promise<Response> { 4 const url = new URL(req.url); 5 const path = url.pathname; 6 7 if (req.method === "GET" && path === "/") { 8 return new Response("Hello, World!"); 9 } else if (req.method === "POST" && path === "/api/todos") { 10 // Handle POST /api/todos 11 } else if (req.method === "GET" && path === "/api/todos") { 12 // Handle GET /api/todos 13 } else if (req.method === "GET" && path === "/api/todos/incomplete/count") { 14 // Handle GET /api/todos/incomplete/count 15 } else if (req.method === "GET" && path.startsWith("/api/todos/")) { 16 // Handle GET /api/todos/:id 17 } else if (req.method === "PUT" && path.startsWith("/api/todos/")) { 18 // Handle PUT /api/todos/:id 19 } else if (req.method === "DELETE" && path.startsWith("/api/todos/")) { 20 // Handle DELETE /api/todos/:id 21 } else { 22 return new Response("Not Found", { status: 404 }); 23 } 24 } 25 26 console.log(`HTTP webserver running. Access it at: http://localhost:${PORT}/`); 27 Deno.serve({ port: PORT }, handler);
This sets up a basic HTTP server using Deno's built-in
Deno.serve
function. The handler
function processes incoming requests, routing them based on the HTTP method and URL path. It includes placeholders for handling various CRUD operations on a "todos" resource, as well as a special route for counting incomplete todos. If no matching route is found, it returns a 404 "Not Found" response. The server listens on port 3000, and a console message is logged to indicate that the server is running.Now, we can start our Deno server by running the following command:
1 deno run --allow-env --allow-read --allow-net --allow-sys --env --watch server.ts
This will start our server and watch for changes to our code. If you make changes, the server will automatically restart. Remember, Deno is very secure by default, so we need to use the appropriate
--allow-*
flags to allow our server to function.Note: With Deno 2.0, you can now use shorthand flags to allow the necessary permissions.
1 deno -ERNS --env --watch server.ts
The
-E
flag allows environment variables, R
allows reading files, N
allows network access, S
allows system access, and --env
allows the use of environment variables.Now, we can test our server by navigating to
http://localhost:3000
in our web browser. We should see "Hello, World!" displayed in the browser.Now that we have a basic server, let's set up our MongoDB connection. We'll use the official MongoDB npm package, which is now fully compatible with Deno 2.0.
First, let's create a new file called
db.ts
:1 import { MongoClient } from "npm:mongodb@5.6.0"; 2 3 const MONGODB_URI = Deno.env.get("MONGODB_URI") || ""; 4 const DB_NAME = Deno.env.get("DB_NAME") || "todo_db"; 5 6 if (!MONGODB_URI) { 7 console.error("MONGODB_URI is not set"); 8 Deno.exit(1); 9 } 10 11 const client = new MongoClient(MONGODB_URI); 12 13 try { 14 await client.connect(); 15 await client.db("admin").command({ ping: 1 }); 16 console.log("Connected to MongoDB"); 17 } catch (error) { 18 console.error("Error connecting to MongoDB:", error); 19 Deno.exit(1); 20 } 21 22 const db = client.db(DB_NAME); 23 const todos = db.collection("todos"); 24 25 export { db, todos };
If you are familiar with Node.js, you’ll notice that Deno does things a bit differently. Instead of using a
package.json
file and downloading all of the packages into the project directory, Deno uses file paths or URLs to reference module imports. Modules do get downloaded and cached locally, but this is done globally and not per project. This eliminates a lot of the bloat that is inherent from Node.js and its node_modules
folder.With Deno 2.0, you can opt to use a package manager like npm or JSR. You can also use an existing Node.js project with Deno and it will utilize the
package.json
file.In this file, we import the
MongoClient
from the official MongoDB npm package and create a new instance of it. We then connect to our MongoDB instance using the MONGODB_URI
environment variable. If the variable is not set, we log an error and exit the process.Once connected, we ping the database to ensure that our connection is working. If the ping fails, we log an error and exit the process.
We define our database and collection and export them so that we can use them in our application.
Before we can use this file, we'll need to set our
MONGODB_URI
and DB_NAME
environment variables. We can do this by creating a .env
file and adding the following:1 MONGODB_URI="...your connection string..." 2 DB_NAME="todo_db"
The easiest way to get your connection string is to use the MongoDB Atlas GUI. If you don't already have an account, sign up for a free-forever tier. Check out the Connect to Your Cluster documentation for more information on how to get your connection string.
At this point, we need to set up each function for each route. These will be responsible for creating, reading, updating, and deleting (CRUD) documents in our MongoDB database.
Let's create a new folder called
controllers
and a file within it called todoController.ts
.In the
todoController.ts
file, we'll first import our todos
collection and the ObjectId
from the mongodb
npm package:1 import { todos } from "../db.ts"; 2 import { ObjectId } from "npm:mongodb@5.6.0";
Now, we can start creating our first route function. We'll call this function
addTodo
. This function will add a new todo item to our database collection.1 // ... imports 2 3 async function addTodo(req: Request): Promise<Response> { 4 try { 5 const body = await req.json(); 6 const result = await todos.insertOne(body); 7 return new Response(JSON.stringify({ id: result.insertedId }), { 8 status: 201, 9 headers: { "Content-Type": "application/json" }, 10 }); 11 } catch (error) { 12 return new Response(JSON.stringify({ error: error.message }), { 13 status: 400, 14 headers: { "Content-Type": "application/json" }, 15 }); 16 } 17 } 18 19 export { addTodo };
The
addTodo
function takes a Request
object, extracts and parses its JSON body, and attempts to insert this data into the todos
collection. On success, it returns a Response
with a 201 status code and the new document's ID. If an error occurs, it returns a 400 status code with the error message. The function uses try-catch for error handling and returns JSON responses, adhering to RESTful API practices.Let's add the
addTodo
function to our handler
function in the server.ts
file.1 import { addTodo } from "./controllers/todoController.ts"; 2 // ... existing code 3 4 async function handler(req: Request): Promise<Response> { 5 // ... existing code 6 else if (req.method === "POST" && path === "/api/todos") { 7 return await addTodo(req); 8 } 9 // ... existing code 10 }
Now, we can test our
addTodo
function. We'll use the curl
command to send a POST request to our create route. Alternatively, you can use a tool like Postman, Insomnia, or Thunder Client in VS Code to send the request.1 curl -X POST http://localhost:3000/api/todos -H "Content-Type: application/json" -d '{"title": "Todo 1", "complete": false}'
You should see a response with a 201 status code and the ID of the new todo.
Let's move on to the read routes. We'll start with a route that gets all of our todos, called
getTodos
. This will go into the todoController.ts
file.1 // ... existing code 2 3 async function getTodos(): Promise<Response> { 4 try { 5 const allTodos = await todos.find().toArray(); 6 return new Response(JSON.stringify(allTodos), { 7 headers: { "Content-Type": "application/json" }, 8 }); 9 } catch (error) { 10 return new Response(JSON.stringify({ error: error.message }), { 11 status: 500, 12 headers: { "Content-Type": "application/json" }, 13 }); 14 } 15 } 16 17 export { addTodo, getTodos };
This function retrieves all todo items from the todos collection using the
find
MongoDB method. If successful, it returns a JSON response containing all the todos with a 200 status code. If an error occurs during the process, it catches the error and returns a JSON response with the error message and a 500 status code. Finally, the function is exported.Let's add this to our
handler
function in the server.ts
file.1 import { addTodo, getTodos } from "./controllers/todoController.ts"; 2 // ... existing code 3 4 async function handler(req: Request): Promise<Response> { 5 // ... existing code 6 else if (req.method === "GET" && path === "/api/todos") { 7 return await getTodos(); 8 } 9 // ... existing code 10 }
Now, we can test our
getTodos
function. We'll use the curl
command to send a GET request to our read route.1 curl http://localhost:3000/api/todos
You should see a response with a 200 status code and a JSON array of todos. Note the
id
in the response. We'll need this for our next route.Next, we'll set up our function to read a single document. We'll call this one
getTodo
and again put this in the todoController.ts
file.1 // ... existing code 2 3 async function getTodo(id: string): Promise<Response> { 4 try { 5 const todo = await todos.findOne({ _id: new ObjectId(id) }); 6 if (!todo) { 7 return new Response(JSON.stringify({ error: "Todo not found" }), { 8 status: 404, 9 headers: { "Content-Type": "application/json" }, 10 }); 11 } 12 return new Response(JSON.stringify(todo), { 13 headers: { "Content-Type": "application/json" }, 14 }); 15 } catch (error) { 16 return new Response(JSON.stringify({ error: error.message }), { 17 status: 500, 18 headers: { "Content-Type": "application/json" }, 19 }); 20 } 21 } 22 23 export { addTodo, getTodos, getTodo };
This function retrieves a single todo item from the todos collection using the
findOne
MongoDB method based on the provided id
. If the todo item is found, it returns a JSON response with the todo data and a 200 status code. If the todo item is not found, it returns a 404 status code with an error message. If an error occurs during the process, it catches the error and returns a JSON response with the error message and a 500 status code. Finally, the function is exported.Let's add this to our
handler
function in the server.ts
file.1 import { addTodo, getTodos, getTodo } from "./controllers/todoController.ts"; 2 // ... existing code 3 4 async function handler(req: Request): Promise<Response> { 5 // ... existing code 6 else if (req.method === "GET" && path.startsWith("/api/todos/")) { 7 const id = path.split("/")[3]; 8 return await getTodo(id); 9 } 10 // ... existing code 11 }
In this route, we need to extract the
id
from the URL path and pass that to our getTodo
function. We can do this by splitting the path at the /
and taking the fourth element (index 3).Now, we can test our
getTodo
function. We'll use the curl
command to send a GET request to our read single route. Remember that _id
we got from the previous test? We'll need to use that here.1 curl http://localhost:3000/api/todos/<...id here...> 2 # example: 3 # curl http://localhost:3000/api/todos/67005eef3bf67a631efc95f6
You should see a response with a 200 status code and a JSON object representing the todo.
Now that we have documents, let's set up our update route to allow us to make changes to existing documents. We'll call this function
updateTodo
.1 // ... existing code 2 3 async function updateTodo(id: string, req: Request): Promise<Response> { 4 try { 5 const body = await req.json(); 6 const result = await todos.updateOne( 7 { _id: new ObjectId(id) }, 8 { $set: body }, 9 ); 10 if (result.matchedCount === 0) { 11 return new Response(JSON.stringify({ error: "Todo not found" }), { 12 status: 404, 13 headers: { "Content-Type": "application/json" }, 14 }); 15 } 16 return new Response(JSON.stringify({ updated: result.modifiedCount }), { 17 headers: { "Content-Type": "application/json" }, 18 }); 19 } catch (error) { 20 return new Response(JSON.stringify({ error: error.message }), { 21 status: 400, 22 headers: { "Content-Type": "application/json" }, 23 }); 24 } 25 } 26 27 export { addTodo, getTodos, getTodo, updateTodo };
This function updates a todo item in the todos collection based on the provided
id
. It extracts the updated data from the request body, uses the updateOne
MongoDBmethod to modify the existing document, and returns a JSON response indicating the number of documents modified. If the todo item is not found, it returns a 404 status code with an error message. If an error occurs during the process, it catches the error and returns a JSON response with the error message and a 400 status code. Finally, the function is exported.Let's add this to our
handler
function in the server.ts
file.1 import { addTodo, getTodos, getTodo, updateTodo } from "./controllers/todoController.ts"; 2 // ... existing code 3 4 async function handler(req: Request): Promise<Response> { 5 // ... existing code 6 else if (req.method === "PUT" && path.startsWith("/api/todos/")) { 7 const id = path.split("/")[3]; 8 return await updateTodo(id, req); 9 } 10 // ... existing code 11 }
This time, we'll pass both the
id
and the request to our updateTodo
function.Now, we can test our
updateTodo
function. We'll use the curl
command to send a PUT request to our update route. You can use the _id
from the previous tests.1 curl -X PUT http://localhost:3000/api/todos/<...id here...> -H "Content-Type: application/json" -d '{"title": "Updated Todo", "complete": true}'
You should see a response with a 200 status code and a JSON object indicating the number of documents modified.
Next, we'll set up our delete route. We'll call this one
deleteTodo
.1 // ... existing code 2 3 async function deleteTodo(id: string): Promise<Response> { 4 try { 5 const result = await todos.deleteOne({ _id: new ObjectId(id) }); 6 if (result.deletedCount === 0) { 7 return new Response(JSON.stringify({ error: "Todo not found" }), { 8 status: 404, 9 headers: { "Content-Type": "application/json" }, 10 }); 11 } 12 return new Response(JSON.stringify({ deleted: result.deletedCount }), { 13 status: 200, 14 headers: { "Content-Type": "application/json" }, 15 }); 16 } catch (error) { 17 return new Response(JSON.stringify({ error: error.message }), { 18 status: 400, 19 headers: { "Content-Type": "application/json" }, 20 }); 21 } 22 } 23 24 export { addTodo, getTodos, getTodo, updateTodo, deleteTodo };
This function deletes a todo item from the todos collection based on the provided
id
. It uses the deleteOne
MongoDB method to remove the document and returns a 200 status code on success along with a JSON response indicating the number of documents deleted. If the todo item is not found, it returns a 404 status code with an error message. If an error occurs during the process, it catches the error and returns a JSON response with the error message and a 400 status code. Finally, the function is exported.Let's add this to our
handler
function in the server.ts
file.1 import { addTodo, getTodos, getTodo, updateTodo, deleteTodo } from "./controllers/todoController.ts"; 2 // ... existing code 3 4 async function handler(req: Request): Promise<Response> { 5 // ... existing code 6 else if (req.method === "DELETE" && path.startsWith("/api/todos/")) { 7 const id = path.split("/")[3]; 8 return await deleteTodo(id); 9 } 10 // ... existing code 11 }
This time, we'll pass the
id
to our deleteTodo
function.Now, we can test our
deleteTodo
function. We'll use the curl
command to send a DELETE request to our delete route.1 curl -X DELETE http://localhost:3000/api/todos/<...id here...>
You should see a response with a 204 status code.
We're going to create one more bonus route. This one will demonstrate a basic aggregation pipeline. We'll call this one
getIncompleteTodos
.1 // ... existing code 2 3 async function getIncompleteTodos(): Promise<Response> { 4 try { 5 const pipeline = [ 6 { $match: { complete: false } }, 7 { $count: "incomplete" }, 8 ]; 9 const result = await todos.aggregate(pipeline).toArray(); 10 const incompleteCount = result[0]?.incomplete || 0; 11 return new Response(JSON.stringify({ incompleteCount }), { 12 headers: { "Content-Type": "application/json" }, 13 }); 14 } catch (error) { 15 return new Response(JSON.stringify({ error: error.message }), { 16 status: 500, 17 headers: { "Content-Type": "application/json" }, 18 }); 19 } 20 } 21 22 export { addTodo, deleteTodo, getIncompleteTodos, getTodo, getTodos, updateTodo };
This function performs an aggregation pipeline on the todos collection to count the number of incomplete todos. It uses the
$match
stage to filter todos where complete
is false
and the $count
stage to count the number of documents that match this criteria. The result is returned as a JSON response with a 200 status code. If an error occurs during the process, it catches the error and returns a JSON response with the error message and a 500 status code.Let's add this to our
handler
function in the server.ts
file.1 import { addTodo, deleteTodo, getTodo, getTodos, updateTodo, getIncompleteTodos } from "./controllers/todoController.ts"; 2 // ... existing code 3 4 async function handler(req: Request): Promise<Response> { 5 // ... existing code 6 else if (req.method === "GET" && path === "/api/todos/incomplete/count") { 7 return await getIncompleteTodos(); 8 } 9 // ... existing code 10 }
Note that this route is placed above the
getTodo
route in the handler
function. This is because the getTodo
route uses a path that matches the beginning of the getIncompleteTodos
route. If we placed getIncompleteTodos
below getTodo
in the handler
function, the server would not be able to distinguish between the two routes and would always match the getTodo
route.Alternatively, we could use a regular expression to match the path and place
getIncompleteTodos
below getTodo
in the handler
function. This would allow us to use a more specific path for our aggregation route.Now, we can test our
getIncompleteTodos
function. We'll use the curl
command to send a GET request to our aggregation route.1 curl http://localhost:3000/api/todos/incomplete/count
You should see a response with a 200 status code and a JSON object containing the count of incomplete todos.
In this tutorial, we created a Deno server that uses the MongoDB driver to create, read, update, and delete (CRUD) documents in our MongoDB database. We added a bonus route to demonstrate using an aggregation pipeline with the MongoDB driver. What next?
The complete code can be found in the Getting Started With Deno & MongoDB repository. You should be able to use this as a starter for your next project and modify it to meet your needs.