nodeexpressmongoose

Create a client agnostic API with Node, Express and Mongoose

September 1, 2022

I've demonstrated how to create a todo list, in React, four times! Four-freaking-times! What if we don't want to use React? What if we want to use Vue? Or plain old HTML? What if we want to use React Native to build a mobile app?

I've got you covered fam. I am going to guide you through creating a client agnostic todo list API server with Node, Express, and Mongoose! That way you can use whatever front-end technology your heart desires.

See the completed code here. This tutorial assumes you have Node installed on your machine.

# create folder for the project and cd into it
mkdir todo-list-api && cd $_
# create a read me for you repo
echo "# Todo List API" >> README.md

Inside the project, initiate Git and create a .gitignore file.

git init
echo "node_modules" >> .gitignore
echo ".env" >> .gitignore
echo ".DS_Store" >> .gitignore # if using a Mac

Next, create a package.json file and use NPM to install our dependencies.

npm init -y
npm i express morgan nodemon dotenv mongoose
  • express - web framework for Node that allows us to write handlers for HTTP requests
  • morgan - a request logger for the console
  • nodemon - restarts the Node server when file changes are detected
  • dotenv - loads environment variables from a .env file
  • mongoose - an Object Data Modelling wrapper that provides a query API for interacting with a MongoDB database

Next add this script to the package.json to use Nodemon to fire up our server.

{
// ...
"scripts": {
"start": "nodemon server.js"
}
// ...
}

The server

Next let's create the simplest form of an Express server in server.js. We will require the top-level express() object and run listen() on it while we pass it a port number. We will also use the Morgan middleware to log our HTTP requests to the console.

const express = require('express');
const morgan = require('morgan');
const port = process.env.PORT || 1337;
const app = express();
app.use(morgan('dev'));
app.listen(port, () => console.log(`Server running on port ${port} 🚀`));

Check to see if the server and Nodemon and working correctly.

npm run start
# ...
[nodemon] starting `node server.js`
Server listening on port 1337 🚀

MongoDB Atlas

Before we go any further we need to create a cluster on MongoDB Atlas. Here is the official tutorial on how to do so. A free, shared cluster will do for this tutorial. Once you've created the cluster, you'll need to get the database connection string in order to proceed.

Create an .env file at the root of your project. Add environment variables for the connection string and the port number. Replace <username>, <password> and <dbname> with the correct values in your own connection string.

DB_URL=mongodb+srv://<username>:<password>@cluster0.l9y8z9u.mongodb.net/<dbname>?retryWrites=true&w=majority
PORT=1337

Connecting to MongoDB is fairly simple using mongoose.connect(). We just need to pass the function our connection string, which contains the path to our cluster and our credentials, and an options object, as arguments. Create a db.js file in ~/todo-list-api/config/.

const mongoose = require('mongoose');
require('dotenv').config();
module.exports = async function () {
try {
await mongoose.connect(process.env.DB_URL, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log('Successfully connected to database 🙌');
} catch (err) {
console.log('Database connection unsuccessful: ', err);
}
};

Now let's require our database connection function and invoke it.

const express = require('express');
const morgan = require('morgan');
const app = express();
const db = require('./config/db');
app.use(morgan('dev'));
db();
app.listen(process.env.PORT, () => {
console.log(`Server running on port ${process.env.PORT} 🚀`);
});

Nodemon should detect file changes, and reload the server for us. We should see in the console both our server running and that we've successfully connected to our MongoDB cluster in the cloud.

Server listening on port 1337 🚀
Successfully connected to database 🙌

Project structure

We will modularize our application according to the Route-Controller-Model-Services paradigm. There is no "right way" to structure your Node app, but this is considered pretty standard.

  • routes - the URL paths, each with it's own corresponding HTTP verb, and route handler - our API endpoints
  • controller - handles our HTTP requests, our parameter and query logic, as well as sending responses with the correct HTTP codes - where our route handler code lives
  • model - the data-layer and model definition
  • services - contains the database queries and return objects

We don't have a complicated API, so we will combine our controller and service layers.

.
├── api
│ └── todo
│ └── model.js
│ └── controller.js
│ └── routes.js
├── lib
│ └── middleware.js
├── config
│ └── db.js
├── node_modules
├── package.json
├── README.md
├── .gitignore
├── .env
└── server.js

Todo model

In Mongoose, a schema defines the stucture of a document, it's default values, and validators. A model is a wrapper around on the schema which provides a set of methods for interacting with the database, such as querying and CRUD operations.

In model.js create the schema for our todo model. The schema is passed as the second argument to the mongoose.model() method, while the first argument is what we will name our model. Later on in the tutorial we will be able to use Mongoose model methods to query, create, update, and delete records in our database.

const mongoose = require('mongoose');
let todoSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
},
complete: {
type: Boolean,
default: false,
required: true,
},
tags: [String],
},
{ timestamps: true }
);
module.exports = mongoose.model('Todo', todoSchema);

MongoDB has built-in, albeit simple, validation. When defining a property on our object, we can specify if it is required. No instance of the model will be saved to the database if a required field is not satisfied. Additionally, we can set a default value to properties - in our case, a newly created todo starts with the property complete set as false.

Creating a todo

It is possible to run a complete Express server from a single file. Because we are modularizing our application, we will create a separate file for our routes and require it in server.js.

const todoRoutes = require('./api/todos/routes');
// ...
app.use(todoRoutes);

The Router() method creates an instance of a router. A router object, in Express, is an isolated instance of middleware and routes. Middleware are functions that execute during the lifecycle of a request to the server.

router.METHOD() - where .METHOD() is one of the HTTP verbs (GET, POST, DELETE, PUT ) - accepts two arguments: 1) the path, and 2) a callback function.

Callback functions used on routing methods are referred to as route handlers. Route handlers are given the request and response objects as arguments. You can name these arguments whatever you want, but for convention you will very often see them written as req and res.

Below we've created our first route - a POST request to the path '/todos'. We've also created our first route handler.

const express = require('express');
const router = express.Router();
router.post('/todos', async (req, res) => {
try {
console.log(req.body);
} catch (err) {
res.status(500).json({ Error: 'Internal Error' });
}
});
module.exports = router;

In Express, the req object represents the HTTP request. The body property on the req object contains the data we send to the server. In our route handler, let's test to see the contents of the req.body by logging it to the console.

We will be using cURL to test our API endpoints. cURL stands for "Client URL" and is a command-line tool for transferring data to a server.

From the terminal, using cURL, make a POST request to our API. We use the -d flag to pass data and -H to pass headers. For reference here is a cURL cheat sheet.

curl -X POST \
-d '{"title": "Gym, Tan, Laundry", "tags": ["fitness", "grooming", "chores"]}' \
-H "Content-Type: application/json" \
http://localhost:1337/todos

Express does not automatically parse the request body for us so we get "undefined" in the console instead of the data we sent with our cURL command.

undefined
POST /todos 200 1.089 ms - -

To fix this we need to require the body-parser middleware. We need to place the bodyParser object before our routes, and because we are sending JSON data via cURL, we need to use the json() method that belongs to bodyParser.

const bodyParser = require('body-parser');
app.use(bodyParser.json());
app.use(todoRoutes);

Now let's try our cURL command again. If we have done everything correctly, we should see the req.body in the console.

POST /todos - - ms - -
{
title: 'Gym, Tan, Laundry',
tags: [ 'fitness', 'grooming', 'chores' ]
}

Now that Express is parsing req.body correctly, we can create a todo. First, we will modularize our app even further and separate our route handler into it's own file.

At the top of controller.js pull in the Todo model. As I mentioned previously, Mongoose models come with a set of methods for interacting with MongoDB.

In the createTodo() function we create a new instance of the Todo model and pass it the request body as it's argument. In MongoDB an instance of a model is called a document.

Then in our try/catch block we call save(), which is a Mongoose model instance method. If our document saves, we send a JSON response with the todo we just created to the client. If the save fails, we send a JSON error message.

const Todo = require('./model');
const createTodo = async (req, res) => {
const todo = new Todo(req.body);
try {
await todo.save();
res.json(todo);
} catch (err) {
res.status(500).json({ Error: 'Internal Error' });
}
};
module.exports = {
createTodo,
};

Now let's refactor routes.js to require our controller methods.

const express = require('express');
const router = express.Router();
const controller = require('./controller');
router.post('/todos', controller.createTodo);
module.exports = router;

From the terminal, run our cURL POST command again. The server should respond with our new todo object.

{
"title": "Gym, Tan, Laundry",
"complete": false,
"tags": [
"fitness",
"grooming",
"chores"
],
"_id": "630e84f9689ed76663a7df66",
"createdAt": "2022-08-30T21:45:29.928Z",
"updatedAt": "2022-08-30T21:45:29.928Z",
"__v": 0
}

For good measure, create a few more todos using our cURL command, as in our next controller method we will fetch all the todos in our database.

Fetching todos

In controller.js create a getTodos() function. Inside our try block, we call find() on our model and save the results to a variable. find() - a model method - retrieves a collection of documents, and accepts a filter object as it's argument. Because we want to retrieve all todos, at this time we will pass nothing to find().

const getTodos = async (req, res) => {
try {
const todos = await Todo.find();
res.json(todos);
} catch (err) {
res.status(500).json({ Error: 'Internal Error' });
}
};
module.exports = {
createTodo,
getTodos,
};

Don't forget to create the route for which we pass our getTodos() function as a handler. This time we are using the get() method on our router. The path will remain '/todos'.

router.get('/todos', controller.getTodos);

Now let's make a GET request via cURL in the terminal. If we've done our job correctly, we should see a JSON array of todo objects.

curl -sG http://localhost:1337/todos \
| jq
[
{
"_id": "630e84f9689ed76663a7df66",
"title": "Gym, Tan, Laundry",
"complete": false,
"tags": [
"fitness",
"grooming",
"chores"
],
"createdAt": "2022-08-30T21:45:29.928Z",
"updatedAt": "2022-08-30T21:45:29.928Z",
"__v": 0
},
{
"_id": "630e8596689ed76663a7df68",
"title": "Gym",
"complete": false,
"tags": [
"fitness"
],
"createdAt": "2022-08-30T21:48:06.235Z",
"updatedAt": "2022-08-30T21:48:06.235Z",
"__v": 0
},
{
"_id": "630e8618689ed76663a7df6e",
"title": "Tan",
"complete": false,
"tags": [
"grooming"
],
"createdAt": "2022-08-30T21:50:16.612Z",
"updatedAt": "2022-08-30T21:50:16.612Z",
"__v": 0
},
{
"_id": "630e8643689ed76663a7df72",
"title": "Laundry",
"complete": false,
"tags": [
"chores"
],
"createdAt": "2022-08-30T21:50:59.309Z",
"updatedAt": "2022-08-30T21:50:59.309Z",
"__v": 0
},
{
"_id": "630e8663689ed76663a7df76",
"title": "GTL",
"complete": false,
"tags": [
"fitness",
"grooming",
"chores"
],
"createdAt": "2022-08-30T21:51:31.467Z",
"updatedAt": "2022-08-30T21:51:31.467Z",
"__v": 0
}
]

Get a single todo

In addition to req.body, the req object also has a params property. The params property is also an object and will contain the URL parameters if there are any. In our route below, Express will use regular expressions to match :id to any string that follows '/todos/' in our URL, and create a property on the params with id as the key and the string as it's value. We can name a param whatever we want, but for convention sake we are going with :id.

router.get('/todos/:id', controller.getTodo);

Let's say we have a todo with the id of 1234. Ideally, the route we would use to fetch that particular todo would be http://localhost:1337/todos/1234. In getTodo() we could grab that id from the URL by accessing the params property on the req object. We then pass it to the findOne() model method. findOne() takes a filter object as it's argument, and in this case, we are looking for the todo with the id that matches the id in our req.params.

const getTodo = async (req, res) => {
try {
const todo = await Todo.findOne({ _id: req.params.id });
res.json(todo);
} catch (err) {
res.status(500).json({ Error: 'Internal Error' });
}
};
module.exports = {
createTodo,
getTodo,
getTodos,
};

If successful we send the todo to the client as JSON. If unsuccessful, we respond with an error message. Test it out with cURL.

curl -sG http://localhost:1337/todos/630e84f9689ed76663a7df66 \
| jq
{
"_id": "630e84f9689ed76663a7df66",
"title": "Gym, Tan, Laundry",
"complete": false,
"tags": [
"fitness",
"grooming",
"chores"
],
"createdAt": "2022-08-30T21:45:29.928Z",
"updatedAt": "2022-08-30T21:45:29.928Z",
"__v": 0
}

Update a todo

The path for updating a todo will be the same as retrieving a todo but the route will have the distinctions of sending a PUT request instead of a GET, and it's own handler: updateTodo().

router.put('/todos/:id', controller.updateTodo);

Create updateTodo() in our controller.js file. findOneAndUpdate() is another model method that accepts a filter object, an object containing the fields we want to update, and an options object.

findOneAndUpdate() is atomic, meaning that it locates the document and updates it all at once. In contrast, when updating a document by calling save() on the instance, you must retrieve it first using findOne().

In findOneAndUpdate() we are retrieving the todo whose id matches the :id in the URL paramaters or req.params, passing in the req.body which has the data we want to update, and an options object. The new property returns the document after update (the default behaviour is to return the document pre-update), and the useFindAndModify property disables deprecation warnings in the console (don't worry this is recommended).

const updateTodo = async (req, res) => {
try {
const todo = await Todo.findOneAndUpdate({ _id: req.params.id }, req.body, {
new: true,
useFindAndModify: false,
});
res.json(todo);
} catch (err) {
res.status(500).json({ Error: 'Internal Error' });
}
};
module.exports = {
createTodo,
getTodo,
getTodos,
updateTodo,
};

Now let's send a put request using cURL. Let's mark this todo as complete by passing an object with complete: true as the field we want to update.

curl -X PUT \
http://localhost:1337/todos/630e84f9689ed76663a7df66 \
-H 'Content-Type: application/json' \
-d '{"complete": true}' \
| jq
{
"_id": "630e84f9689ed76663a7df66",
"title": "Gym, Tan, Laundry",
"complete": true,
"tags": [
"fitness",
"grooming",
"chores"
],
"createdAt": "2022-08-30T21:45:29.928Z",
"updatedAt": "2022-08-30T22:10:37.641Z",
"__v": 0
}

Delete a todo

Much like updating a todo, we need an id to retrieve the particular todo from the database we want to delete. With that said, our DELETE request's path will need an :id parameter much like GET and PUT.

router.delete('/todos/:id', controller.deleteTodo);

We pass the id from req.params to deleteOne()'s filter object. deleteOne() deletes the first document that matches the filter.

const deleteTodo = async (req, res) => {
try {
await Todo.deleteOne({ _id: req.params.id });
res.json({ Success: 'Successfully deleted todo.' });
} catch (err) {
res.status(500).json({ Error: 'Internal Error' });
}
};
module.exports = {
createTodo,
deleteTodo,
getTodo,
getTodos,
updateTodo,
};

Once resolved, we send a success message to the client.

curl -X DELETE http://localhost:1337/posts/63046ce46440276676025579
{"Success":"Successfully deleted todo."}

CORS

So far we've been testing our API on the command-line with cURL. Let's see what happens if we try and log our data to the client, or more specifically, the browser console.

Create an index.html, and make a fetch() call to our server.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Todo List API</title>
</head>
<body>
<script>
fetch('http://localhost:1337/posts')
.then(response => response.json())
.then(data => console.log(data));
</script>
</body>
</html>

If we open the browser console we see we get an error.

Access to fetch at 'http://localhost:1337/todos' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

This is expected because "cross-domain" requests are blocked by default. This is a browser security feature to stop nefarious websites from stealing your data. If we were using Node to render our HTML, we would not have this issue as the HTML and server are of "same origin".

To fix this, we have to enable cross-origin resource sharing (CORS) in our application. Inside of our middleware file we will create a function that sets the response headers, and then calls next(). next() is function which simply tells Node to run the next middleware function in the sequence, which in our case is our route handlers.

const cors = (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
res.setHeader(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept, Cache-Control'
);
next();
};

Require our middleware in the server.js file.

const middleware = require('./lib/middleware');

Order matters when we call our middleware. Our cors() function needs to precede our routes otherwise the response headers will not be set for each route call. Node's Express().use() function is a catch-all - it applies the middleware regardless of HTTP method.

app.use(middleware.cors);
app.use(todosRoutes);

Now if we refresh our index.html we should see our fetch call is working and we are successfully logging our Todos to the browser console.

(5) [{…}, {…}, {…}, {…}, {…}]

404 Not Found

Currently, if we tried to access a route on our API that did not exist, Node will return HTML. This is not ideal. If we want our API to be client agnostic we need to return JSON.

curl http://localhost:1337/todoss #typo
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /todoss</pre>
</body>
</html>

Create a function in middleware.js that accepts the req and res arguments, and returns a response with the 404 status and a JSON error message.

const notFound = (req, res) =>
res.status(404).json({ Error: 'Resource Not Found' });
module.exports = {
notFound,
};

The order which we call our middleware is important. If no request matches any of our route handlers, our notFound() middleware will run. In server.js call notFound() after the call to our routes.

app.use(todoRoutes);
app.use(middleware.notFound);

Now if we try to access that non-existent resource again, we will get our JSON error instead of HTML.

curl http://localhost:1337/todoss
{"Error":"Resource Not Found"}

Cleaning up the controller

We have a bit of redundancy - there is a try/catch block in each one of our controller functions. The catch block specifically repeats the same JSON error message. A try/catch block is how we normally handle errors in async/await, but we can create a higher order function and apply it to each our route handlers to make our code more DRY.

A higher order function is a function that either accepts or returns another function. In our case, our catchWrapper() function does both. It accepts our route handler as it's argument and returns an async function that has the req, res and next arguments. Because we are returning an async function we can use a try/catch block inside it. Inside the try block we await the successful execution of our handler code, while passing it the req, res and nextarguments. If our handler fails, the catch block will pass the error to the next() function, which will call our handleError() middleware function.

module.exports = function (handler) {
return async function (req, res, next) {
try {
await handler(req, res, next);
} catch (err) {
next(err);
}
};
};

Inside our middleware file, create the handleError() function. In Express, error handling functions have access to an error argument, which is the first argument. You must pass all four arguments (err, req, res, and next) in order to access it, otherwise Express will recognize the first argument as req.

const handleError = (err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({ Error: 'Internal Error' });
};
module.exports = {
cors,
handleError,
notFound,
};

We never really want the end user to know what exactly is going on beneath the hood when there is an error. Exposing the end user to sever errors makes our code vulnerable to attack. This is why for the API user we simply send back a status of 500 and a generic message of "Internal Error", while for ourselves we log the actual error to the console.

Modify the routes file to use our catchWrapper() function to "wrap" each route handler.

const catchWrapper = require('../lib/catch-wrapper');
router.get('/todos', catchWrapper(controller.getTodos));
router.post('/todos', catchWrapper(controller.createTodo));
router.get('/todos/:id', catchWrapper(controller.getTodo));
router.put('/todos/:id', catchWrapper(controller.updateTodo));
router.delete('/todos/:id', catchWrapper(controller.deleteTodo));

In our server.js place the handleError() middleware function after our routes but before notFound(). Remember how our controller functions are wrapped in a try/catch block inside of our catchWrapper() function? Remember how the catch block passes the error to next()? Well the next middleware in the sequence is handleError().

app.use(cors);
app.use(todoRoutes);
app.use(middleware.handleError);
app.use(middleware.notFound);

Now we can refactor our controller.js to be more succinct and without the repetitive try/catch blocks. If none of our controller functions resolve correctly, the error will be thrown to the handleError() middleware.

const getTodo = async (req, res) => {
const todo = await Todo.findOne({ _id: req.params.id });
res.json(todo);
};
const getTodos = async (req, res) => {
const todos = await Todo.find();
res.json(todos);
};
const createTodo = async (req, res) => {
const todo = new Todo(req.body);
await todo.save();
res.json(todo);
};
const updateTodo = async (req, res) => {
const todo = await Todo.findOneAndUpdate({ _id: req.params.id }, req.body, {
new: true,
useFindAndModify: false,
});
res.json(todo);
};
const deleteTodo = async (req, res) => {
await Todo.deleteOne({ _id: req.params.id });
res.json({ Success: 'Successfully deleted todo!' });
};

Query strings

The req object has quite a few properties, with body and params being two that we looked at. req also has a query property, which is an object. req.query contains a property for each query string parameter that is in the route.

For the URL route http://localhost:1337/posts?page=1&limit=2&tag=fitness the req.query object woud look like:

{
"page": 1,
"limit": 2,
"tag": "fitness"
}

Like parameters, we can use query strings to send data with our database queries. Let's refactor getTodos() in controller.js to accept query strings and paginate our data.

const getTodos = async (req, res) => {
const { limit = 0, page = 0, tag } = req.query;
let filter = {};
if (tag) filter.tags = tag;
const todos = await Todo.find(filter)
.skip(page * limit)
.limit(limit);
const totalCount = await Todo.countDocuments(filter);
res.json({
todos,
perPage: todos.length,
totalPages: Math.ceil(totalCount / limit),
totalCount,
});
};

Create variables for limit, page, and tag by destructuring the req.query object. Give limit and page default values of 0 just incase neither query string is passed, we show the complete list of todos starting from the beginning.

Previously, we passed zero arguments to our find() model method because we wanted to retrieve all todos. This time we programmatically build our filter starting with declaring an empty filter object. Next we check to see if the tag variable returns truthy - that is, it is not undefined, nor does it contain an empty string. If truthy, we set filter.tags to tag. In our todo schema, tags is defined as an array of strings. In Mongoose, searching an array field for a string value is as simple as writing a filter object like { tags: "fitness" }. If tag is falsy, the filter remains an empty object, which is as good as passing nothing to find().

Which todos our query produces is wholly dependent on the filter, and the limit and page values provided by the query string. We chain to the find() method two other methods: skip() and limit(). For skip() we pass in the value for page * limit, while for limit() we pass in limit.

For example, if we have 10 documents, and the value of limit is 2 and page is 2, then our find() query would return the next two todos (5, 6), starting from 5. Technically 5,6 are on the 3rd page, because the first page is actually 0. And the next page would consist of todos 7,8, and the next page after that 9,10.

countDocuments() is another model method which returns - you guessed it - the number of documents in a collection. countDocuments() also takes a filter object as an argument. The totalCount variable is the total number of documents with the filter applied, or in our case the total number of documents with a specific tag.

To round off our paginated collection of todos we return an object with the following properties: todos, perPage (the length of the todos array), totalPages (totalCount divided by the limit), and totalCount.

Let's test our query strings in cURL:

curl -sG http://localhost:1337/todos\?tag\='fitness' | jq
{
"todos": [
{
"_id": "630e84f9689ed76663a7df66",
"title": "Gym, Tan, Laundry",
"complete": true,
"tags": [
"fitness",
"grooming",
"chores"
],
"createdAt": "2022-08-30T21:45:29.928Z",
"updatedAt": "2022-08-30T22:10:37.641Z",
"__v": 0
},
{
"_id": "630e8596689ed76663a7df68",
"title": "Gym",
"complete": false,
"tags": [
"fitness"
],
"createdAt": "2022-08-30T21:48:06.235Z",
"updatedAt": "2022-08-30T21:48:06.235Z",
"__v": 0
},
{
"_id": "630e8d017794f16ff10a53fd",
"title": "GTL",
"complete": false,
"tags": [
"fitness",
"grooming",
"chores"
],
"createdAt": "2022-08-30T22:19:45.611Z",
"updatedAt": "2022-08-30T22:19:45.611Z",
"__v": 0
}
],
"perPage": 3,
"totalPages": null,
"totalCount": 3
}

Now with all three query string parameters:

curl -sG http://localhost:1337/todos\?tag\='fitness'\&limit\=2\&page\=0 | jq
{
"todos": [
{
"_id": "630e84f9689ed76663a7df66",
"title": "Gym, Tan, Laundry",
"complete": true,
"tags": [
"fitness",
"grooming",
"chores"
],
"createdAt": "2022-08-30T21:45:29.928Z",
"updatedAt": "2022-08-30T22:10:37.641Z",
"__v": 0
},
{
"_id": "630e8596689ed76663a7df68",
"title": "Gym",
"complete": false,
"tags": [
"fitness"
],
"createdAt": "2022-08-30T21:48:06.235Z",
"updatedAt": "2022-08-30T21:48:06.235Z",
"__v": 0
}
],
"perPage": 2,
"totalPages": 2,
"totalCount": 3
}

There you have it, a Node/Express REST API that is client agnostic. A while back I simulated a fake REST API using JSON-Server and React. We could replace JSON-Server in that project with this Express server and would have to change very little code to get it working.

It is still a basic API, as we have not explored relationships like has many, belongs to, and we've barely scratched the surface as far as Express or Mongoose is concerned.

Updating a GatsbyJS website: breaking changes everywhere

Previous

Use Apollo GraphQL to over-engineer a todo list app

Next

© 2023. Made with ❤️ for Max 👶🏻.