Full Stack
How to Build a MERN Stack App: From Tech Stack to Deployment
MERN is four technologies: MongoDB, Express, React and Node.js. Most tutorials teach them one at a time, which leaves the most important question unanswered: how do they actually connect to make one working app?
This guide walks through building a MERN app from the first plan to a live URL, using a simple task tracker as the example. At each step you'll see where one technology hands off to the next.
What each part does
| Technology | Where it runs | Its job |
|---|---|---|
| React | Browser | The user interface: pages, forms, buttons and the state behind them |
| Express | Server | The API: receives requests, checks them and sends back JSON |
| Node.js | Server | The runtime that runs your Express code (JavaScript outside the browser) |
| MongoDB | Database | Stores your data as JSON-like documents |
The key idea: the browser never talks to the database. React calls your Express API over HTTP, Express talks to MongoDB, and data travels back as JSON. Because every layer is JavaScript and every layer speaks JSON, the same data shape flows through the whole app.
Step 1: Plan the data and the API
Before writing code, decide two things: what you store and which endpoints the UI needs. For the task tracker, a task has a title, a done flag and an owner. The UI needs to list, create, update and delete tasks:
| Method | Endpoint | What it does |
|---|---|---|
| GET | /api/tasks | List the current user's tasks |
| POST | /api/tasks | Create a task |
| PATCH | /api/tasks/:id | Update a task (e.g. mark it done) |
| DELETE | /api/tasks/:id | Delete a task |
This list is the contract between your frontend and backend. Agree on it first and both sides can be built independently.
Step 2: Set up the project
Keep the frontend and backend in two folders in one repository. Each has its own package.json.
task-tracker/
client/ # React app (created with Vite)
server/ # Node.js + Express API
models/ # Mongoose schemas
routes/ # Express routes
middleware/ # auth, error handling
.env # secrets - never commit thisStep 3: MongoDB - define the data
Create a free cluster on MongoDB Atlas and put its connection string in server/.env. Then use Mongoose to describe what a task looks like. The schema gives you validation and a model to query with.
// server/models/Task.js
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true },
done: { type: Boolean, default: false },
owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
},
{ timestamps: true } // adds createdAt and updatedAt
);
module.exports = mongoose.model('Task', taskSchema);Step 4: Express on Node.js - build the API
Express is where the connections meet. It receives requests from React, runs middleware, and uses the Mongoose model to read or write MongoDB.
// server/index.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const Task = require('./models/Task');
const requireAuth = require('./middleware/requireAuth');
const app = express();
// Allow the React app to call us, with its login cookie
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json()); // parse JSON request bodies
// React -> Express -> MongoDB -> Express -> React
app.get('/api/tasks', requireAuth, async (req, res) => {
const tasks = await Task.find({ owner: req.user.id }).sort({ createdAt: -1 });
res.json(tasks);
});
app.post('/api/tasks', requireAuth, async (req, res) => {
const task = await Task.create({ title: req.body.title, owner: req.user.id });
res.status(201).json(task);
});
mongoose.connect(process.env.MONGODB_URI).then(() => {
app.listen(process.env.PORT || 4000, () => console.log('API running'));
});Test each endpoint with Postman or Thunder Client before building the UI. If the API works on its own, any later bug is on the frontend side - which halves your debugging.
Step 5: React - build the UI and call the API
Create the frontend with npm create vite@latest client. Components fetch data from the API, keep it in state and re-render when it changes.
// client/src/TaskList.jsx
import { useEffect, useState } from 'react';
const API = import.meta.env.VITE_API_URL;
export default function TaskList() {
const [tasks, setTasks] = useState([]);
useEffect(() => {
fetch(`${API}/api/tasks`, { credentials: 'include' })
.then((res) => res.json())
.then(setTasks); // the same JSON MongoDB returned, now in React state
}, []);
return (
<ul>
{tasks.map((task) => (
<li key={task._id}>{task.title}</li>
))}
</ul>
);
}Notice task._id: that field was created by MongoDB, passed through Express untouched, and is now used as a React key. That's the "same JSON everywhere" idea in practice.
Step 6: Add authentication
Users need their own tasks. The usual MERN approach:
- On sign-up, Express hashes the password with bcrypt and saves the user in MongoDB.
- On login, Express checks the password and issues a JWT, ideally in an
httpOnlycookie so JavaScript in the page can't read it. - A
requireAuthmiddleware verifies the token on every protected route and setsreq.user. - React keeps the logged-in user in context and redirects to the login page when the API answers
401.
Step 7: Handle errors and test
- Validate input on the server - never trust what the browser sends.
- Add one error-handling middleware in Express so every failure returns a clear status code and message.
- Show loading and error states in React, not just the happy path.
- Write a few API tests (Jest + Supertest) for the endpoints that matter most.
Step 8: Deploy
| Part | Where to host it | Settings to remember |
|---|---|---|
| MongoDB | MongoDB Atlas | Allow your server's IP in Network Access |
| Express + Node.js | Render or Railway | MONGODB_URI, JWT_SECRET, CLIENT_URL as environment variables |
| React | Netlify or Vercel | VITE_API_URL pointing to the live API, plus an SPA redirect to index.html |
Most first deployments fail on configuration, not code: a CORS origin that still says localhost, a missing environment variable, or cookies blocked because the frontend and API are on different domains. Check those three first.
The finished-project checklist
- Secrets live in environment variables, and
.envis in.gitignore. - Every protected route checks the user, and users can only touch their own data.
- The app shows loading, empty and error states.
- The README explains what the app does, the tech stack and how to run it locally.
- It's live, with a link you can put on your resume.
Build this once end to end and you'll be able to answer the most common MERN interview question - "walk me through what happens when a user clicks Save" - from real experience.
Want to go deeper on one layer? These free lessons cover each part: MongoDB documents and collections and CRUD operations, Express routing and route parameters, React components and props and state with useState, and how Node.js works including the event loop.
Keep going: MERN Stack roadmap
Start now