MERN Stack Tutorial: Build a Full-Stack App from Scratch
A complete MERN stack tutorial — build a full-stack app with MongoDB, Express, React, and Node.js from scratch, including authentication, REST API, and deployment.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
MERN Stack Tutorial: Build a Full-Stack App from Scratch
The first time I tried to understand a full-stack application, I felt like I was trying to see all four floors of a building at once while standing inside it. Which layer handles authentication? How does data flow from the React component to the database and back? Where do environment variables live?
MERN makes sense as a stack because JavaScript flows through every layer — the same language that runs in the browser also runs the server and structures the data. Once you see one complete request cycle from React component to MongoDB and back, the architecture clicks.
This tutorial builds a task management API with complete CRUD operations, JWT authentication, and a working React frontend — everything connected and deployed by the end.
Project Architecture Overview
We're building a Task Manager with:
- MongoDB Atlas: Cloud database (tasks and users)
- Express.js + Node.js: REST API backend (authentication, CRUD endpoints)
- React + Vite: Frontend (task list UI, forms, auth flow)
Client (React) Server (Node/Express) Database (MongoDB)
────────────── ────────────────────── ─────────────────
POST /api/auth/login ──→ Validate credentials ──→ Find user by email
←── JWT token ←── User document
GET /api/tasks ────────→ Verify JWT token ──→ Query tasks by userId
←──────── Tasks array ←── Task documents
POST /api/tasks ───────→ Validate input ──→ Insert new task
←──────── New task object ←── Created document
Part 1: Backend Setup
Initialize the Project
mkdir mern-tasks && cd mern-tasks
mkdir server && cd server
npm init -y
npm install express mongoose dotenv bcryptjs jsonwebtoken cors express-validator
npm install --save-dev nodemon
Project Structure
server/
├── models/
│ ├── User.js
│ └── Task.js
├── routes/
│ ├── auth.js
│ └── tasks.js
├── middleware/
│ └── auth.js
├── .env
└── server.js
server.js — Express Application
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();
const app = express();
// Middleware
app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173' }));
app.use(express.json());
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/tasks', require('./routes/tasks'));
// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB connection error:', err));
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
.env File
MONGODB_URI=mongodb+srv://your-connection-string
JWT_SECRET=your-jwt-secret-min-32-chars
PORT=5000
CLIENT_URL=http://localhost:5173
Mongoose Models
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
password: { type: String, required: true, minlength: 6 },
}, { timestamps: true });
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
// Compare password method
userSchema.methods.comparePassword = function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
// models/Task.js
const mongoose = require('mongoose');
const taskSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
description: { type: String, trim: true },
completed: { type: Boolean, default: false },
priority: {
type: String,
enum: ['low', 'medium', 'high'],
default: 'medium',
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
}, { timestamps: true });
module.exports = mongoose.model('Task', taskSchema);
Authentication Middleware
// middleware/auth.js
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
Auth Routes
// routes/auth.js
const router = require('express').Router();
const jwt = require('jsonwebtoken');
const User = require('../models/User');
// POST /api/auth/register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(409).json({ error: 'Email already in use' });
}
const user = new User({ name, email, password });
await user.save();
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
expiresIn: '7d',
});
res.status(201).json({
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
expiresIn: '7d',
});
res.json({
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;
Tasks Routes
// routes/tasks.js
const router = require('express').Router();
const auth = require('../middleware/auth');
const Task = require('../models/Task');
// All task routes require authentication
router.use(auth);
// GET /api/tasks — Get all tasks for authenticated user
router.get('/', async (req, res) => {
try {
const tasks = await Task.find({ user: req.userId })
.sort({ createdAt: -1 });
res.json(tasks);
} catch {
res.status(500).json({ error: 'Server error' });
}
});
// POST /api/tasks — Create new task
router.post('/', async (req, res) => {
try {
const task = new Task({ ...req.body, user: req.userId });
await task.save();
res.status(201).json(task);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// PATCH /api/tasks/:id — Update task
router.patch('/:id', async (req, res) => {
try {
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, user: req.userId },
req.body,
{ new: true, runValidators: true }
);
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json(task);
} catch {
res.status(500).json({ error: 'Server error' });
}
});
// DELETE /api/tasks/:id — Delete task
router.delete('/:id', async (req, res) => {
try {
const task = await Task.findOneAndDelete({
_id: req.params.id,
user: req.userId,
});
if (!task) return res.status(404).json({ error: 'Task not found' });
res.json({ message: 'Task deleted' });
} catch {
res.status(500).json({ error: 'Server error' });
}
});
module.exports = router;
Part 2: React Frontend
cd ..
npm create vite@latest client -- --template react
cd client
npm install axios react-router-dom
Key React Concepts
Authentication State Management with Context:
// context/AuthContext.jsx
import { createContext, useContext, useState } from 'react';
import axios from 'axios';
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(
() => JSON.parse(localStorage.getItem('user'))
);
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
const login = async (email, password) => {
const { data } = await axios.post('/api/auth/login', { email, password });
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
axios.defaults.headers.common['Authorization'] = `Bearer ${data.token}`;
setUser(data.user);
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
delete axios.defaults.headers.common['Authorization'];
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
Deployment
MongoDB Atlas: Create free cluster at mongodb.com/atlas. Get connection string.
Backend (Railway):
- Push code to GitHub
- Connect Railway to GitHub repo
- Set environment variables (MONGODB_URI, JWT_SECRET, CLIENT_URL)
- Deploy
Frontend (Vercel):
- Set
VITE_API_URLto your Railway URL - Connect Vercel to GitHub frontend repo
- Deploy automatically
For deeper SQL database understanding alongside MongoDB, see our SQL guide and the full full-stack roadmap.
Frequently Asked Questions
What is the MERN stack and why is it popular?
MongoDB, Express, React, Node.js. Popular because all components use JavaScript — one language across the entire stack. Large community, npm ecosystem, and used at many coding bootcamps.
Is MERN good for beginners?
If you know JavaScript and React, the backend pieces are learnable in weeks. The architecture can be confusing at first. Following a structured tutorial provides the necessary framework.
Should I use MERN or PERN?
For most apps, PERN (PostgreSQL instead of MongoDB) is stronger. PostgreSQL handles relational data and complex queries better. Use MongoDB when your data model is truly flexible or changes frequently.
What is the difference between Express and Next.js?
Express is a backend framework for Node.js. Next.js is a React framework that includes API routes — it can replace both Express and standalone React in one framework.
How do I deploy a MERN app?
MongoDB Atlas (database) + Railway or Render (backend) + Vercel or Netlify (frontend). All have free tiers. Setup takes under an hour after your first deployment.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How I Built a Full-Stack App in 48 Hours Using AI Tools
Learn how to use AI tools to build a full-stack app fast — GitHub Copilot, Claude, and ChatGPT for planning, coding, debugging, and deploying a real web application in 48 hours.
The 2025 Full Stack Developer Roadmap: From Zero to Job-Ready
The complete full stack developer roadmap for 2025 — learn frontend, backend, databases, DevOps, and the exact learning path from beginner to job-ready in 12–18 months.
The Full Stack Developer Salary Guide for 2025 by Country
Full stack developer salary guide 2025 — average salaries by country, experience level, tech stack, and remote work, plus tips to negotiate a higher salary.
How to Get Your First Full-Stack Job Without a CS Degree
Full stack job no degree guide — how self-taught developers and bootcamp grads land their first software job with a portfolio, networking, and interview prep.