I’ve really enjoyed learning the MERN stack this year. Building with MongoDB, Express, React and Node has given me a different way to think about an application, from the records it stores through to the interface someone uses. The dashboard I’ve been working on has been a useful place to put that learning into practice.
Working alongside other developers using MERN, and talking through the things we were building, helped a lot. I could follow a tutorial on my own, but explaining a problem to someone else often made the missing piece much clearer. Conversations about state, API requests and the shape of the data improved my understanding as much as writing the code.
I treated the dashboard as a practical way to learn the full stack. There are unfinished areas, but having a set of screens and records to work with made the decisions much easier to understand. These examples are shortened from the project, with proposed improvements labelled separately.
What the dashboard was designed to do
The main idea was a personal dashboard for organising everyday information. The most developed part is a finance view, where someone can create account cards, enter a balance and add the bills associated with each account. It works with manually entered information; it isn’t connected to a bank or moving money.
A bill can have a monthly amount, a remaining amount, a category, a date and a flag for whether it is active or paid. The interface uses that information to work out totals and show what is left against an account balance. It gave me something more interesting to build than a list of records, because changing one item also changes the picture around it.
There is a bookmarks section too, with names, categories and URLs, plus an experimental Code section and subtask work. Some of the Code area still reuses the account-card structure, so I wouldn’t describe it as a finished code-snippet tool. The broader intention was a dashboard with separate areas that could grow as I learned.
How the four parts of MERN fit together
MongoDB stores the data. Express defines the API routes and handles requests on the server. React builds the interface in the browser, and Node runs the server-side JavaScript. Mongoose sits between the Express code and MongoDB, giving me models, schemas and query methods.
For a bookmark, the journey is fairly easy to follow. A React form collects the name, category and URL, then sends JSON to an Express route. The server verifies the login token, gets the user identifier from it and creates a bookmark through Mongoose. MongoDB stores the document, and the response tells React whether it can add the new item to the list.
That flow was a big part of the appeal for me. I could follow familiar JavaScript objects through the interface, request body and model. It didn’t remove the need to think about types or validation, but it reduced the amount of switching between languages while I was learning.
Getting used to documents in MongoDB
MongoDB was the part that changed my thinking most. Rather than starting with tables and rows, I was working with collections and documents. The project has collections for users, account cards, bookmarks, code records and subtasks. Each document describes one item, and can contain nested objects or arrays.
The structure looks familiar when you are used to JavaScript, but MongoDB stores documents as BSON, a binary format with more types than ordinary JSON. Dates and ObjectIds are examples. JSON is how the API represents its response to the browser; it isn’t a promise that every database type reaches the browser unchanged. The BSON type documentation helped me understand the distinction.
MongoDB allows documents in a collection to have different fields. That makes it convenient to develop an idea and add information as the application grows. It doesn’t mean the data should be allowed to become inconsistent. I still need a clear idea of what a bookmark or an account card is, and how the rest of the code expects to read it. MongoDB’s data modelling guidance makes that point well.
Mongoose gave me a place to express that structure. Here is a shortened version of the bookmark model:
const mongoose = require('mongoose');
const bookmarkSchema = new mongoose.Schema(
{
uuid: { type: String, required: true },
user_uuid: { type: String, required: true },
name: { type: String, required: true },
category: { type: String },
url: { type: String },
active: { type: Boolean, default: false },
},
{ collection: 'bookmark-data' }
);
const Bookmark = mongoose.model('BookmarkData', bookmarkSchema);
The required fields make the minimum shape clear, and the Boolean default gives active a known starting value. Mongoose also creates its usual _id field; the project’s uuid is a separate identifier used by the application. A schema is useful documentation as well as code, because another developer can see the intended record without searching through every form. The Mongoose 6 schema guide explains how that model maps to a collection.
There are limits to what declaring a type proves. A String field for a URL does not establish that it is a valid, safe web address. I need input validation around the model too. Similarly, Mongoose update methods need the appropriate validator options when I want schema validation on an update; update validators are not enabled by default.
Deciding what belongs inside a document
The account-card model stores its bill items in a data array. That means the account details and the information needed to display its bills can be retrieved together. This invented example shows the shape used by the interface, with the internal database fields left out:
{
"uuid": "example-account",
"user_uuid": "example-user",
"name": "Household account",
"totalInAccount": "500.00",
"data": [
{
"id": "example-bill",
"name": "Broadband",
"pm": "30.00",
"remaining": "0.00",
"category": "Essential",
"incoming": "Outgoing",
"active": true,
"paid": false
}
]
}
Embedding those items made sense for exploring an account view, because the bills are normally displayed with the account they belong to. The app also has a separate bill model in the repository, but the card interface shown here uses the embedded array. That distinction matters when explaining how the actual screen works.
There is a different choice for users. Their details live in a separate collection, and other records carry a user_uuid. I don’t need to copy the user’s name and email into every bookmark. Those identifiers are application references, not SQL foreign-key constraints that automatically enforce every relationship for me.
The choice between embedding and references depends on how the information is read and changed. If I wanted a large, independently searchable history of bills across accounts, I’d revisit the embedded array. A document cannot grow indefinitely, and rewriting an entire array gets awkward when several requests might change different items at once.
I’d also make the financial fields more deliberate before taking that part further. The early version keeps amounts as strings and converts them with Number() for the interface calculations. For reliable money calculations, I’d choose a consistent representation such as integer minor units, with the currency and rounding rules defined. The general lesson is that a flexible database still needs precise decisions about the data.
Express connects the interface to the records
Express makes the API fairly straightforward to organise. The app enables JSON request parsing and mounts a router at /api. Routes then handle operations such as retrieving cards, updating an account and creating or deleting a bookmark. Node is the runtime executing those handlers; Express is the framework deciding which handler receives a request. The Express routing guide is a useful reference for that separation.
An important detail in the existing bookmark route is where the user identifier comes from. The server verifies the token and uses its identity when querying the collection, instead of accepting a user identifier sent in the request body. This is the central query, shortened from the handler. It assumes the route has already verified the token and assigned decoded:
async function sendBookmarks(res, decoded) {
const bookmarks = await Bookmark.find({
user_uuid: decoded.uuid,
});
return res.json({
status: 'ok',
data: bookmarks,
});
}
Updates use both the item’s uuid and the authenticated user_uuid in the filter. That makes ownership part of the database lookup. I’d still validate the requested fields, check whether an update actually matched a record and return useful error responses. An API should tell the interface what happened, rather than only saying that it received the request.
React makes the dashboard feel immediate
The frontend uses React components for the dashboard shell, account cards, bill editors and bookmarks, with React Router for moving between sections. Forms use state to hold the values being edited. When a request succeeds, the component updates its state and React renders the new list without loading a completely new page.
The bookmark-loading function follows this pattern. I’ve shortened it and added explicit error handling to show the version I would aim for inside the component:
async function populateBookmarks() {
const response = await fetch(apiBase + 'api/bookmark', {
headers: {
'x-access-token': localStorage.getItem('token'),
},
});
const result = await response.json();
if (!response.ok || result.status !== 'ok') {
throw new Error('Could not load bookmarks');
}
setBookmarks(result.data);
}
Here, apiBase is the configured API address and setBookmarks is the component’s state setter. Keeping those responsibilities visible helped me understand what belongs on the client and what belongs on the server. React can decide whether a panel is open or how to display a list. The server must decide what the user is allowed to read or change.
I also wrote a useLocalStorage hook to retain the card, code and bookmark state between visits. It made returning to a section feel quick while I was developing it. That browser copy is a cache, though. It can be stale, and it is neither a backup nor proof that an update reached MongoDB. Some actions update the interface before a request finishes, so I’d add a clear saving state and a way to recover when a save fails.
MongoDB performance still needs some thought
The most common queries in this app start with the user identifier, and updates add an item identifier. As the collections grow, I’d add indexes that support those access patterns and measure the queries. MongoDB’s index documentation explains why a suitable index can avoid scanning the entire collection.
For example, this is a proposed compound index for finding a particular user-owned bookmark. I’d declare it on the schema before calling mongoose.model(). It is an addition I’d make, not an index already declared in the original bookmark model:
bookmarkSchema.index({ user_uuid: 1, uuid: 1 });
I’d check the query plan rather than assume that declaring an index makes every request faster. Indexes take storage and add work when records change. I’d also keep the response to the fields the screen needs and paginate lists as they grow. A quick development stack is not a substitute for a sensible data model or careful queries.
Before using the prototype for real accounts, I’d revisit its early authentication code too: proper password hashing, server-held secrets, expiring tokens and safer handling of browser storage all need attention. Decoding a token in React can help display information, but only server-side verification can establish its identity. That is work to complete, not something I’d claim this learning build has already solved.
Learning it with other developers
Talking about MERN with other developers greatly improved the learning process for me. It pushed me to explain why I’d put a field in a document, why a component needed its own state or why a request should return a particular response. If I couldn’t explain a decision clearly, it usually meant I needed to understand it better.
It also made getting stuck less frustrating. Someone else might have met the same problem, or might approach it from a different direction. I enjoyed that exchange. It helped me move beyond copying a working example and towards knowing what I could change without losing track of how the application fitted together.
Why MERN feels like a big part of what comes next
At the end of 2022, this feels like a big part of the future of development to me. It is quick to build with, dynamic in the browser and fantastic to explore when the data and interface need to develop together. I can make a change to a form, follow it through an API route and see it reflected in a stored document, which makes the whole process feel connected.
That is my enthusiasm for the way of working, rather than a claim that one stack will suit every project. I still enjoy Laravel and the structure it gives an application. Learning MERN has widened the options I can bring to a problem, and made me think more carefully about the experience I want to build before choosing the tools.
I’ve enjoyed getting into the full stack rather than treating the database, API and interface as separate jobs. The dashboard gave me a practical reason to learn each part and see how a decision in one affects the others. There is plenty I want to improve, but it has made me keen to keep working with MongoDB and React. The code is in my dashboard app repository.