Build and Deploy a MERN Task Manager
Build a full-stack Task Manager with React, Express, and MongoDB, then deploy it to AWS.
Introduction
30 Second Summary
Everyone has a to-do list. Whether it's tracking work deadlines, personal errands, or learning goals, task management is something you deal with every single day.
In this project, you will build and deploy a full-stack Task Manager application using the MERN stack. You'll create a React frontend, an Express backend API, and a MongoDB cloud database, then deploy everything live to the internet.
What You'll Build
A full-stack Task Manager application where users can create, view, complete, and delete tasks through a React frontend connected to an Express REST API with MongoDB persistence.
By the end of this project, you'll have:
- A React frontend built with Vite featuring a clean task interface with add, complete, and delete functionality.
- An Express REST API with full CRUD operations deployed live on Render.
- A MongoDB Atlas cloud database storing your task documents, accessible from anywhere.
- A live Vercel deployment of your frontend with automatic deploys from GitHub.
- Secret Mission: Add task categories with color-coded labels and a filter dropdown.
Are there any prerequisites?
You should have basic JavaScript knowledge and some familiarity with HTML and CSS. No prior experience with React, Express, or MongoDB is required.
Set Up MongoDB Atlas
Your backend needs a database to store tasks. Instead of installing a database on your computer, you will use a cloud-hosted database called MongoDB Atlas.
Atlas gives you a free database that lives in the cloud, so your data persists even after you close your laptop. It also means your deployed app can reach the same data from anywhere.
In this step, get ready to:
- Create a free MongoDB Atlas account and cluster.
- Create a database user and configure network access.
- Copy your connection string for use in the backend.
Create a MongoDB Atlas account
MongoDB Atlas is a Database-as-a-Service that hosts your MongoDB database in the cloud. The free M0 tier gives you 512 MB of storage with no credit card required.
- Open your browser and go to cloud.mongodb.com.
- Click Try Free and sign up with your email, Google, or GitHub account.
- Complete email verification if prompted.
What is MongoDB Atlas?
MongoDB is a document database that stores data as flexible JSON-like objects instead of rigid rows and columns. MongoDB Atlas is the cloud-hosted version, so you do not need to install or manage anything locally.
The free M0 cluster provides 512 MB of storage with no time limit. It is perfect for learning and development projects like this one.
Create a free M0 cluster
After signing in, Atlas walks you through creating your first cluster. A cluster is the group of servers that hosts your database.
- On the cluster creation page, select the M0 (Free) option.
- Choose a cloud provider (AWS, Google Cloud, or Azure). Any of them work fine.
- Select the region closest to you.
- Leave the cluster name as Cluster0 or give it a custom name.
- Click Create to deploy the cluster.
The cluster takes less than a minute to provision. Once ready, Atlas opens the Security Quickstart wizard.
Why use a cloud database instead of a local one?
A local MongoDB install only works on your machine. When you deploy your app to the internet later in this project, the deployed server cannot reach a database running on your laptop.
Atlas gives your app a single connection string that works from anywhere, whether you are running locally or deployed on Render.
Create a database user and configure network access
Atlas blocks all connections by default. You need to create a database user (the username and password your app uses to authenticate) and allow network access so your app can connect.
- In the Security Quickstart, choose Username and Password authentication.
- Enter a username (for example, taskmanager-user).
- Click Autogenerate Secure Password and copy the generated password somewhere safe.
- Click Create Database User.
Save your password now
You will need this password in the next step when you build the backend. Atlas does not show the password again after this screen. Paste it into a temporary text file or password manager.
Now configure network access to allow connections from anywhere during development.
- In the sidebar, click Network Access under the Security heading.
- Click Add IP Address.
- Click Allow Access from Anywhere. This adds 0.0.0.0/0 to the access list.
- Click Confirm.
Why allow access from anywhere?
Adding 0.0.0.0/0 means any IP address can connect to your database (with valid credentials). This is fine for a learning project because your database user password still protects the data.
For a production app, you would restrict access to specific IP addresses. For now, this setting ensures both your local machine and your deployed Render server can connect without issues.
Finally, copy your connection string so you can use it in your backend code.
- Return to the cluster overview by clicking Database in the sidebar.
- Click Connect on your cluster.
- Select Drivers.
- Choose Node.js as your driver.
- Copy the connection string. It looks like this:
mongodb+srv://your-username:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority
- Replace <password> with the password you saved earlier.
- Add /taskmanager before the ? in the connection string. This tells MongoDB which database to use. Your final connection string looks like:
mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/taskmanager?retryWrites=true&w=majority
- Save this connection string somewhere safe. You will use it in the next step.
What does the connection string do?
The connection string is a URL that tells your app where the database lives and how to authenticate. The mongodb+srv:// prefix tells the MongoDB driver to use DNS-based discovery to find the cluster servers automatically.
The /taskmanager part names the database. MongoDB creates it automatically the first time you write data to it.
Having trouble with Atlas setup?
If you cannot find the connection string, go back to Database in the left sidebar, click Connect on your cluster, and select Drivers.
If you forgot your database user password, go to Database Access in the sidebar and click Edit on your user to set a new password.
Help me troubleshoot my MongoDB Atlas setup.
Your cloud database is ready to go. In the next step, you will build the Express backend that connects to this database and serves task data through a REST API.
Build the Express Backend API
You have a MongoDB Atlas cluster ready and waiting for data. Now you need an application that can talk to it.
In this step, you will build a REST API using Express and Mongoose. This API handles creating, reading, updating, and deleting tasks. It is the bridge between your future React frontend and your MongoDB Atlas database.
In this step, get ready to:
- Initialize a Node.js project and install backend dependencies.
- Define a Mongoose schema and model for tasks.
- Build four REST API routes for full CRUD functionality.
Initialize the backend project and install dependencies
Your project needs a dedicated folder for the backend code, separate from the frontend you will build later.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Create the backend folder and move into it by running these commands:
mkdir server
cd server
- Initialize a new Node.js project by running:
npm init -y
This creates a package.json file that tracks your project's metadata and dependencies.
- Install the five packages your backend needs by running:
npm install express@5.2.1 mongoose@9.6.2 cors@2.8.6 dotenv@17.4.2 helmet@8.2.0
What are these packages?
- express is the web framework that handles HTTP requests and responses.
- mongoose connects to MongoDB and lets you define data structures.
- cors allows your frontend (on a different domain) to talk to this API.
- dotenv loads secret values from a .env file into your code.
- helmet adds security headers to protect your API from common attacks.
- Confirm the install succeeded by running:
ls
You should see node_modules, package.json, and package-lock.json listed in the output.
Seeing permission errors or EACCES?
This usually means your terminal does not have write access to the folder. Close VS Code and reopen it, then try the install command again.
Help me fix this permission error
Now you need to update package.json with the correct project metadata and start scripts.
- In VS Code, click on package.json in the Explorer sidebar to open it.
- Select all the content (Cmd+A on macOS or Ctrl+A on Windows) and replace it with this:
{
"name": "mern-task-manager-api",
"version": "1.0.0",
"description": "Task Manager REST API",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "2.8.6",
"dotenv": "17.4.2",
"express": "5.2.1",
"helmet": "8.2.0",
"mongoose": "9.6.2"
}
}
What do the scripts do?
- start runs your server normally. Render will use this in production.
- dev runs the server with --watch, which auto-restarts whenever you save changes.
- Create a .gitignore file to keep sensitive files and bulky folders out of version control. Click File in the top menu, then New File.
- Name the file .gitignore and make sure it is saved inside the server folder.
- Add this content to the file:
node_modules
.env
- Save both package.json and .gitignore.
Why exclude these files?
The node_modules folder contains thousands of downloaded dependency files that anyone can recreate with npm install. The .env file will hold your database password, which must never be pushed to GitHub.
Create the server file and define the task model
Your backend needs a single entry point that sets up Express, connects to MongoDB, and defines what a task looks like in the database.
- Create a new file called server.js in the server folder. Click File then New File, and name it server.js.
- Add the import and middleware setup by pasting this code:
// Load environment variables and import dependencies
const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");
const helmet = require("helmet");
require("dotenv").config();
// Create Express app and apply middleware
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
What does this code do?
- require("dotenv").config() reads your .env file and makes its values available as process.env variables.
- helmet() sets security-related HTTP headers automatically.
- cors() allows requests from other domains (your frontend will live on a different URL).
- express.json() parses incoming JSON request bodies so you can read them in your route handlers.
- Add the MongoDB connection logic directly below the middleware section:
// Connect to MongoDB Atlas using the connection string from .env
mongoose
.connect(process.env.MONGODB_URI)
.then(() => console.log("Connected to MongoDB Atlas"))
.catch((err) => console.error("MongoDB connection error:", err));
What is happening with the connection?
Mongoose uses the MONGODB_URI environment variable (which you will set up in the next step) to connect to your Atlas cluster. The .then() and .catch() log whether the connection succeeded or failed.
Now define what a task looks like in the database. A Mongoose schema describes the fields each task document will have.
- Add the task schema and model below the connection code:
// Define the shape of a task document in MongoDB
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
},
completed: {
type: Boolean,
default: false,
},
createdAt: {
type: Date,
default: Date.now,
},
});
// Create the Task model from the schema
const Task = mongoose.model("Task", taskSchema);
What does the schema define?
- title is a required string. The trim: true removes extra whitespace.
- completed defaults to false so every new task starts as incomplete.
- createdAt records when the task was made, which lets you sort tasks by date.
- mongoose.model("Task", taskSchema) creates a model you can use to read and write task documents.
- Save server.js.
You will not see a visible change yet. The next section adds routes that actually use this model.
Build the REST API routes
Your API needs four endpoints, one for each CRUD operation: create, read, update, and delete. Each route maps to a standard HTTP method.
- In server.js, add the GET and POST routes below the Task model:
// GET all tasks, sorted by newest first
app.get("/api/tasks", async (req, res) => {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
});
// POST a new task
app.post("/api/tasks", async (req, res) => {
const task = new Task({
title: req.body.title,
});
const savedTask = await task.save();
res.status(201).json(savedTask);
});
What do these routes do?
- GET /api/tasks fetches every task from the database, sorted with the newest first.
- POST /api/tasks creates a new task from the title in the request body. It responds with status 201 (created) and the saved task object.
- Save server.js.
You will not see output yet. The server needs the remaining routes and a listen call before it can start.
- Add the PATCH route below the POST route to handle updating a task:
// PATCH (update) a task by ID
app.patch("/api/tasks/:id", async (req, res) => {
const task = await Task.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
if (!task) {
return res.status(404).json({ error: "Task not found" });
}
res.json(task);
});
What does PATCH do here?
PATCH /api/tasks/:id finds a task by its ID and updates it with whatever fields are in the request body. The { new: true } option tells Mongoose to return the updated document instead of the old one. If no task matches the ID, it responds with a 404 error.
- Add the DELETE route and the server startup code below the PATCH route:
// DELETE a task by ID
app.delete("/api/tasks/:id", async (req, res) => {
const task = await Task.findByIdAndDelete(req.params.id);
if (!task) {
return res.status(404).json({ error: "Task not found" });
}
res.json({ message: "Task deleted" });
});
// Start the server on the specified port
const PORT = process.env.PORT || 5000;
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- DELETE /api/tasks/:id permanently removes a task from the database.
- process.env.PORT || 5000 uses port 5000 locally but lets hosting platforms assign their own port.
- "0.0.0.0" binds to all network interfaces, which Render requires for deployment.
- Save server.js.
Your API code is complete. You cannot start the server yet because it needs the MongoDB connection string, which you will configure in the next step.
Seeing red underlines or errors in VS Code?
Make sure every opening brace { has a matching closing brace }. Check that every parenthesis and bracket is closed. Use the full-file reference below to compare your code.
Help me find the syntax error in my server.js
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files: package.json, .gitignore, and server.js.
ⓧ I'd like to double check the full code
Here is the complete server.js file. Compare yours line by line:
const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");
const helmet = require("helmet");
require("dotenv").config();
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
mongoose
.connect(process.env.MONGODB_URI)
.then(() => console.log("Connected to MongoDB Atlas"))
.catch((err) => console.error("MongoDB connection error:", err));
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
},
completed: {
type: Boolean,
default: false,
},
createdAt: {
type: Date,
default: Date.now,
},
});
const Task = mongoose.model("Task", taskSchema);
app.get("/api/tasks", async (req, res) => {
const tasks = await Task.find().sort({ createdAt: -1 });
res.json(tasks);
});
app.post("/api/tasks", async (req, res) => {
const task = new Task({
title: req.body.title,
});
const savedTask = await task.save();
res.status(201).json(savedTask);
});
app.patch("/api/tasks/:id", async (req, res) => {
const task = await Task.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
if (!task) {
return res.status(404).json({ error: "Task not found" });
}
res.json(task);
});
app.delete("/api/tasks/:id", async (req, res) => {
const task = await Task.findByIdAndDelete(req.params.id);
if (!task) {
return res.status(404).json({ error: "Task not found" });
}
res.json({ message: "Task deleted" });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server running on port ${PORT}`);
});
And here is the complete package.json:
{
"name": "mern-task-manager-api",
"version": "1.0.0",
"description": "Task Manager REST API",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"cors": "2.8.6",
"dotenv": "17.4.2",
"express": "5.2.1",
"helmet": "8.2.0",
"mongoose": "9.6.2"
}
}
And the .gitignore:
node_modules
.env
Your backend API is fully built. Next up, you will create a .env file with your MongoDB Atlas connection string and test every endpoint to make sure it works.
Test the Backend API Locally
Your backend code is ready with Express routes and a Mongoose model. Before deploying anything, you need to connect the server to your real MongoDB Atlas database and confirm every endpoint works correctly.
In this step, you will create an environment variables file, start the server, and test each CRUD operation against your live database.
In this step, get ready to:
- Create the .env file with your MongoDB Atlas connection string.
- Test all four CRUD endpoints locally.
- Verify tasks persist in MongoDB Atlas.
Create the environment variables file
Your server code uses process.env.MONGODB_URI to connect to the database. Environment variables let you keep sensitive values like database credentials out of your code and out of version control.
- In VS Code, create a new file called .env inside your server/ folder.
- Add your MongoDB Atlas connection string and port number by pasting this into the file:
MONGODB_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/taskmanager?retryWrites=true&w=majority
PORT=5000
- Replace your-username with the database username you created in MongoDB Atlas.
- Replace your-password with the password you set for that user.
- Replace cluster0.xxxxx.mongodb.net with the cluster hostname from your Atlas connection string.
What do these variables do?
MONGODB_URI tells Mongoose where your database lives. The /taskmanager at the end of the URI is the database name. Mongoose creates it automatically when you save your first document.
PORT sets which port the Express server listens on. Your code falls back to 5000 if no PORT variable exists, but setting it explicitly keeps things predictable.
- Save .env.
Your .env is already git-ignored
The .gitignore file you created earlier includes .env. This means your credentials will never be committed to GitHub.
Not sure where to find your connection string?
Return to your MongoDB Atlas dashboard, click **Connect** on your cluster, choose **Drivers**, and copy the connection string shown there. Replace <password> with your database user's actual password.
Help me find my MongoDB Atlas connection string.
Start the server and test the API endpoints
With your environment variables in place, the server can now connect to MongoDB Atlas. You will start the server, then use a second terminal to send HTTP requests to each endpoint.
- Make sure your terminal is inside the server/ folder, then start the development server by running:
npm run dev
What should I see?
Two messages should appear in the terminal. The first is Server running on port 5000 confirming Express is listening. The second is Connected to MongoDB Atlas confirming Mongoose successfully reached your cluster.
Seeing a MongoDB connection error?
Double-check your .env file. The most common issues are a typo in the password, a missing /taskmanager database name at the end of the URI, or network access not set to allow from anywhere in Atlas.
Help me debug my MongoDB Atlas connection error.
Leave this terminal running. You need a second terminal to send test requests to the server.
- Open a new terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- Test the POST endpoint by creating a new task. Run this command:
macOS
curl -X POST http://localhost:5000/api/tasks -H "Content-Type: application/json" -d '{"title":"Buy groceries"}'
Windows
Invoke-RestMethod -Method POST -Uri "http://localhost:5000/api/tasks" -ContentType "application/json" -Body '{"title":"Buy groceries"}'
What does the response show?
You should see a JSON object with a title of Buy groceries, a completed field set to false, a createdAt timestamp, and an _id field. Copy the _id value. You will need it for the next two tests.
- Test the GET endpoint by opening your browser and navigating to:
http://localhost:5000/api/tasks
You should see a JSON array containing the task you just created.
- Test the PATCH endpoint by marking your task as completed. Replace the ID placeholder below with the _id you copied from the POST response:
macOS
curl -X PATCH http://localhost:5000/api/tasks/[[TASK_ID="paste your task _id here"]] -H "Content-Type: application/json" -d '{"completed":true}'
Windows
Invoke-RestMethod -Method PATCH -Uri "http://localhost:5000/api/tasks/[[TASK_ID="paste your task _id here"]]" -ContentType "application/json" -Body '{"completed":true}'
The response shows the same task, but now completed is true.
- Test the DELETE endpoint by removing the task. Use the same _id:
macOS
curl -X DELETE http://localhost:5000/api/tasks/[[TASK_ID="paste your task _id here"]]
Windows
Invoke-RestMethod -Method DELETE -Uri "http://localhost:5000/api/tasks/[[TASK_ID="paste your task _id here"]]"
You should see a JSON response with the message Task deleted.
- Refresh http://localhost:5000/api/tasks in your browser. The array should be empty again, confirming the task was removed.
Getting "connection refused" or no response?
Make sure your first terminal still shows the server running. If you accidentally closed it, run npm run dev again from inside the server/ folder.
Help me troubleshoot my Express server not responding to requests.
Verify tasks persist in MongoDB Atlas
The API works locally, but you should also confirm that data is actually stored in your cloud database. This proves Mongoose is writing to Atlas, not just holding data in memory.
- Create one more task so there is data to see. Run the POST command again with a different title:
macOS
curl -X POST http://localhost:5000/api/tasks -H "Content-Type: application/json" -d '{"title":"Learn MERN stack"}'
Windows
Invoke-RestMethod -Method POST -Uri "http://localhost:5000/api/tasks" -ContentType "application/json" -Body '{"title":"Learn MERN stack"}'
- Return to your MongoDB Atlas dashboard in the browser.
- Click **Database** in the left sidebar.
- Click **Browse Collections** on your cluster.
- Select the taskmanager database, then the tasks collection.
You should see your task document listed with a title, completed, and createdAt field. This confirms your API is writing to the real cloud database.
Don't see the taskmanager database?
Click the refresh icon in the Atlas collections view. The database only appears after the first document is successfully saved. If it still does not appear, check that your .env connection string includes /taskmanager before the ?retryWrites query parameter.
Help me find my task data in MongoDB Atlas.
✔️ Awesome, I've got everything!
Great. Double check you have saved your .env file and that your server is still running.
ⓧ I'd like to double check the full code
Here is the complete .env file (the only new file from this step):
MONGODB_URI=mongodb+srv://your-username:your-password@cluster0.xxxxx.mongodb.net/taskmanager?retryWrites=true&w=majority
PORT=5000
Your backend is fully functional and connected to a cloud database. Next up, you will build the React frontend that gives users a visual interface for managing their tasks.
Build the React Frontend with Vite
Your backend API is live and responding to requests. Now you need a visual interface so users can actually see and interact with their tasks.
React is a JavaScript library for building user interfaces from reusable components. You will use Vite to scaffold a React project in seconds, then build a clean task management interface that communicates with your Express API.
In this step, get ready to:
- Scaffold a new React project with Vite.
- Build the task manager UI with add, complete, and delete functionality.
- Configure the frontend to communicate with your backend API.
Scaffold the React app with Vite
Vite is a modern build tool that creates React projects with instant startup and fast hot module replacement. You will scaffold a new React app inside a client folder alongside your existing server folder.
- Open a new terminal in VS Code by pressing Ctrl+` (backtick key, next to the 1 key).
- Make sure you are in your project's root folder (the parent folder that contains server/).
- Scaffold the React project by running this command:
npm create vite@latest client -- --template react
What does this command do?
This command downloads Vite's project scaffolding tool and creates a new folder called client pre-configured with React.
The --template react flag tells Vite to set up a React project specifically, skipping any interactive prompts about framework selection.
Seeing a prompt about installing a package?
npm may ask you to confirm installing the create-vite package. Type y and press Enter to continue.
- Move into the new client folder and install its dependencies by running these commands:
cd client
npm install
- Confirm the installation succeeded by running:
ls src
You should see files including App.jsx, App.css, and main.jsx. These are the starter files Vite created for you.
Command not found or permission error?
Make sure you are in the project root folder (not inside server/) before running the scaffold command. If you get a permissions error on Windows, try running your terminal as administrator.
help me troubleshoot the Vite scaffold command
Build the task list UI
Now you will build the actual task manager interface. The main component handles four operations: fetching tasks from the API, adding new tasks, toggling completion, and deleting tasks.
- In VS Code's Explorer sidebar, expand client/src/ and click on App.jsx to open it.
- Select all the existing code and delete it.
- Paste the following code that sets up the component's state and data loading:
import { useState, useEffect } from "react";
import "./App.css";
// Use environment variable for API URL, fall back to localhost for development
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:5000";
function App() {
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState("");
const [loading, setLoading] = useState(true);
// Fetch tasks from the API when the component first loads
useEffect(() => {
fetchTasks();
}, []);
async function fetchTasks() {
const response = await fetch(`${API_URL}/api/tasks`);
const data = await response.json();
setTasks(data);
setLoading(false);
}
What does this code do?
API_URL reads from an environment variable so you can point the frontend at different backend URLs in development vs production.
useState creates three pieces of state: tasks (the list from the API), newTask (what the user is typing), and loading (whether data is still being fetched).
useEffect with an empty array [] runs fetchTasks() once when the component first appears on screen.
- Directly below the fetchTasks function, paste the three CRUD handler functions:
// Add a new task to the database
async function addTask(e) {
e.preventDefault();
if (!newTask.trim()) return;
const response = await fetch(`${API_URL}/api/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTask }),
});
const task = await response.json();
setTasks([task, ...tasks]);
setNewTask("");
}
// Toggle a task's completed status
async function toggleTask(id, completed) {
const response = await fetch(`${API_URL}/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !completed }),
});
const updatedTask = await response.json();
setTasks(tasks.map((t) => (t._id === id ? updatedTask : t)));
}
// Delete a task permanently
async function deleteTask(id) {
await fetch(`${API_URL}/api/tasks/${id}`, { method: "DELETE" });
setTasks(tasks.filter((t) => t._id !== id));
}
What do these functions do?
addTask sends a POST request to create a new task, then adds the response to the top of the list without needing a full page refresh.
toggleTask sends a PATCH request to flip the completed boolean, then updates just that one task in the local state.
deleteTask sends a DELETE request, then removes the task from the local array so the UI updates instantly.
- Directly below the deleteTask function, paste the JSX that renders the UI:
// Show loading state while fetching
if (loading) return <div className="app"><p>Loading tasks...</p></div>;
return (
<div className="app">
<h1>Task Manager</h1>
<form onSubmit={addTask} className="task-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
className="task-input"
/>
<button type="submit" className="add-btn">
Add
</button>
</form>
<ul className="task-list">
{tasks.map((task) => (
<li key={task._id} className={`task-item ${task.completed ? "completed" : ""}`}>
<span onClick={() => toggleTask(task._id, task.completed)} className="task-title">
{task.title}
</span>
<button onClick={() => deleteTask(task._id)} className="delete-btn">
Delete
</button>
</li>
))}
</ul>
{tasks.length === 0 && <p className="empty-message">No tasks yet. Add one above!</p>}
</div>
);
}
export default App;
What does the JSX render?
The form at the top calls addTask on submit. The input field is controlled by the newTask state so React always knows what the user typed.
The tasks.map() loop renders each task as a list item. Clicking the task title toggles completion. Clicking the Delete button removes it.
- Save App.jsx.
Seeing red squiggly lines in App.jsx?
VS Code might show warnings for JSX syntax if the file extension is wrong. Make sure the file is named App.jsx (not App.js). The .jsx extension tells VS Code to expect React syntax.
help me fix JSX syntax errors in VS Code
Now you will add the styles that make the task manager look polished.
- In the Explorer sidebar, click on App.css (inside client/src/).
- Select all the existing code and delete it.
- Paste the following styles:
.app {
max-width: 600px;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
}
.task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.task-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.task-input:focus {
border-color: #4361ee;
}
.add-btn {
padding: 0.75rem 1.5rem;
background: #4361ee;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #3a56d4;
}
.task-list {
list-style: none;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
margin-bottom: 0.5rem;
background: #f8f9fa;
border-radius: 8px;
transition: background 0.2s;
}
.task-item:hover {
background: #e9ecef;
}
.task-item.completed .task-title {
text-decoration: line-through;
color: #6c757d;
}
.task-title {
cursor: pointer;
flex: 1;
font-size: 1rem;
}
.delete-btn {
padding: 0.4rem 0.8rem;
background: #ef476f;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s;
}
.delete-btn:hover {
background: #d63d5e;
}
.empty-message {
text-align: center;
color: #6c757d;
font-style: italic;
}
What do these styles do?
The .app container centers everything at a max width of 600px. The form uses flexbox to put the input and button side by side.
Each .task-item gets a light background with hover effects. Completed tasks show strikethrough text via the .completed .task-title selector.
- Save App.css.
✔️ Awesome, I've got everything!
Great. Double check you have saved both App.jsx and App.css.
ⓧ I'd like to double check the full code
Here is the full App.jsx:
import { useState, useEffect } from "react";
import "./App.css";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:5000";
function App() {
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchTasks();
}, []);
async function fetchTasks() {
const response = await fetch(`${API_URL}/api/tasks`);
const data = await response.json();
setTasks(data);
setLoading(false);
}
async function addTask(e) {
e.preventDefault();
if (!newTask.trim()) return;
const response = await fetch(`${API_URL}/api/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTask }),
});
const task = await response.json();
setTasks([task, ...tasks]);
setNewTask("");
}
async function toggleTask(id, completed) {
const response = await fetch(`${API_URL}/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !completed }),
});
const updatedTask = await response.json();
setTasks(tasks.map((t) => (t._id === id ? updatedTask : t)));
}
async function deleteTask(id) {
await fetch(`${API_URL}/api/tasks/${id}`, { method: "DELETE" });
setTasks(tasks.filter((t) => t._id !== id));
}
if (loading) return <div className="app"><p>Loading tasks...</p></div>;
return (
<div className="app">
<h1>Task Manager</h1>
<form onSubmit={addTask} className="task-form">
<input
type="text"
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Add a new task..."
className="task-input"
/>
<button type="submit" className="add-btn">
Add
</button>
</form>
<ul className="task-list">
{tasks.map((task) => (
<li key={task._id} className={`task-item ${task.completed ? "completed" : ""}`}>
<span onClick={() => toggleTask(task._id, task.completed)} className="task-title">
{task.title}
</span>
<button onClick={() => deleteTask(task._id)} className="delete-btn">
Delete
</button>
</li>
))}
</ul>
{tasks.length === 0 && <p className="empty-message">No tasks yet. Add one above!</p>}
</div>
);
}
export default App;
Here is the full App.css:
.app {
max-width: 600px;
margin: 2rem auto;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
color: #1a1a2e;
margin-bottom: 2rem;
}
.task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.task-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.task-input:focus {
border-color: #4361ee;
}
.add-btn {
padding: 0.75rem 1.5rem;
background: #4361ee;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #3a56d4;
}
.task-list {
list-style: none;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
margin-bottom: 0.5rem;
background: #f8f9fa;
border-radius: 8px;
transition: background 0.2s;
}
.task-item:hover {
background: #e9ecef;
}
.task-item.completed .task-title {
text-decoration: line-through;
color: #6c757d;
}
.task-title {
cursor: pointer;
flex: 1;
font-size: 1rem;
}
.delete-btn {
padding: 0.4rem 0.8rem;
background: #ef476f;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s;
}
.delete-btn:hover {
background: #d63d5e;
}
.empty-message {
text-align: center;
color: #6c757d;
font-style: italic;
}
Connect the frontend to the backend API
Your React component already references API_URL for all its fetch calls. Now you need to configure Vite so those requests reach your Express server during local development.
Why use a proxy?
During development, your React app runs on one port (usually 5173) and your Express server runs on port 5000. Browsers block requests between different ports by default due to CORS rules.
The Vite proxy forwards any /api request from the frontend to your Express backend behind the scenes. This avoids CORS issues entirely during local development.
- In the Explorer sidebar, click on vite.config.js (in the root of the client/ folder).
- Select all the existing code and replace it with the following:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Proxy /api requests to Express backend during development
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:5000",
changeOrigin: true,
},
},
},
});
What does this configuration do?
The proxy section tells Vite's dev server to intercept any request starting with /api and forward it to http://localhost:5000. Your React code does not need to know which port the backend uses during development.
changeOrigin: true updates the request's host header to match the target, which prevents potential issues with virtual hosts.
- Save vite.config.js.
Now create a .env file so Vite knows where your backend lives.
- In the Explorer sidebar, right-click on the client folder and select New File.
- Name the file .env and press Enter.
- Paste the following line into the file:
VITE_API_URL=http://localhost:5000
Why prefix with VITE_?
Vite only exposes environment variables that start with VITE_ to your frontend code. This prevents accidentally leaking server-side secrets into the browser bundle.
In production, you will set VITE_API_URL to your deployed backend URL instead of localhost.
- Save .env.
Time to test everything working together. You need two terminal sessions: one for the Express backend and one for the Vite frontend.
- Open a second terminal in VS Code by clicking the + icon in the terminal panel.
- In the new terminal, start your Express backend by running:
cd ../server
npm run dev
You should see "Connected to MongoDB Atlas" and "Server running on port 5000" in that terminal.
- Switch back to your first terminal (which should still be in the client/ folder).
- Start the Vite development server by running:
npm run dev
Vite will display a local URL (typically http://localhost:5173). Hold Ctrl (or Cmd on macOS) and click the URL to open it in your browser.
You should see the Task Manager heading with an input field and an Add button. If you created tasks in Step 4, they will appear in the list. Try adding a new task, clicking a task to mark it complete, and deleting a task.
Seeing a blank page or network errors?
Open your browser's developer tools (F12) and check the Console tab. If you see CORS or fetch errors, make sure your Express server is running in the other terminal window.
Also verify your .env file is in the client/ folder (not inside client/src/). After changing .env, you must restart the Vite dev server.
help me debug my React frontend connecting to the Express backend
Your full-stack app is running locally. The React frontend fetches data from Express, which reads and writes to MongoDB Atlas. Next up, you will deploy the backend so it is accessible from anywhere on the internet.
Deploy the Backend to Render
Your backend API works perfectly on your machine, but it can only be accessed from your computer right now. To let the React frontend (and the rest of the internet) talk to your API, you need to deploy it to a hosting service.
Render is a cloud platform that can run your Express server for free. You'll push your backend code to GitHub, then connect that repository to Render so it automatically builds and deploys your API to a live public URL.
In this step, get ready to:
- Push the backend code to a GitHub repository.
- Create a free Web Service on Render connected to your GitHub repo.
- Verify the live API responds at its public URL.
Push the backend code to GitHub
Render deploys code directly from a Git repository. You need to put your backend code on GitHub so Render can pull it and build your server.
- In your terminal, make sure you are inside the server folder by running:
cd server
- Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit"
What do these commands do?
- git init creates a new Git repository inside your server folder.
- git add . stages all your files (except those listed in .gitignore) for the commit.
- git commit -m saves a snapshot of your code with a descriptive message.
Now you need a GitHub repository to push this code to.
- Open your browser and go to github.com.
- In the upper-right corner, click the + icon, then click New repository.
- In the Repository name field, type mern-task-manager-api.
- Set the visibility to Public.
- Do NOT check any of the initialization options (no README, no .gitignore, no license).
- Click Create repository.
Why skip the initialization options?
You already have code locally with its own .gitignore. Adding a README or .gitignore during GitHub repo creation would cause a merge conflict when you push your existing code.
After clicking Create repository, GitHub shows a Quick Setup page with instructions. You'll use the commands for pushing an existing repository.
- Back in your terminal (still inside the server folder), connect your local repo to GitHub and push by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/mern-task-manager-api.git"]]
git branch -M main
git push -u origin main
Where do I find my repository URL?
Copy the HTTPS URL from the Quick Setup page that GitHub showed you after creating the repository. It looks like https://github.com/your-username/mern-task-manager-api.git.
- Refresh your GitHub repository page in the browser.
- You should see your server.js, package.json, and .gitignore files listed. The .env file and node_modules folder should NOT appear because they are in your .gitignore.
Push failed or authentication error?
- If you see a permission or authentication error, GitHub may need you to sign in. A browser window should pop up asking you to authorize Git. Follow the prompts to authenticate.
- Make sure the URL you pasted matches your repository exactly. Check for typos in your username or repository name.
Help me push my backend code to GitHub
Create a free Web Service on Render
With your code on GitHub, Render can now pull it and deploy your API server. Render's free tier gives you 750 instance hours per month, which is more than enough for a learning project.
- Open your browser and go to render.com.
- Click Sign Up and create a free account. You can sign up with your GitHub account for the fastest setup.
- Once signed in, in the Render Dashboard, click + New in the top-right corner.
- Select Web Service from the dropdown.
What is a Web Service on Render?
A Web Service is Render's service type for hosting dynamic applications like your Express API. It gives your server a public URL so anyone on the internet can send requests to it.
- If prompted, click Git Provider and connect your GitHub account. Authorize Render to access your repositories.
- Find and select your mern-task-manager-api repository from the list.
- Click Connect.
The service creation form appears. Fill it out with these settings:
- In the Name field, enter mern-task-manager-api. This name becomes part of your public URL.
- Leave the Branch set to main.
- Set Language to Node.
- Set the Build Command to npm install.
- Set the Start Command to node server.js.
- Under instance type, select the Free tier.
Why use node server.js instead of npm start?
Both work since your package.json defines "start": "node server.js". Using node server.js directly is slightly more explicit. Render runs this command to launch your server after installing dependencies.
Now you need to add your MongoDB connection string as an environment variable. Your .env file is not on GitHub (it is in your .gitignore), so Render needs the value configured separately.
- Scroll down to the Advanced section and expand it.
- Click Add Environment Variable.
- In the Key field, enter MONGODB_URI.
- In the Value field, paste your MongoDB Atlas connection string (the same one from your local .env file).
- Click Create Web Service.
How does this replace the .env file?
On Render, environment variables are configured in the dashboard instead of a .env file. When your code runs process.env.MONGODB_URI, it reads the value you set here. This keeps your database credentials out of your source code.
Render now kicks off your first build and deploy. You can watch the progress on the service's Events page in the dashboard.
- Wait for the deploy to finish. The status will change to Live when it succeeds. This typically takes 1-3 minutes.
- Once live, your service's public URL appears at the top of the page. It will look something like https://mern-task-manager-api.onrender.com.
Deploy failed?
- Check the build logs on the Events page. A common error is a typo in the Build Command or Start Command. Make sure they are npm install and node server.js respectively.
- If you see a MongoDB connection error in the logs, double-check that the MONGODB_URI environment variable is set correctly. Make sure you included your actual password (not the <password> placeholder).
- Verify that your MongoDB Atlas network access is set to allow connections from anywhere (0.0.0.0/0).
Help me fix my Render deployment
Verify the live API responds
Your backend is now deployed and accessible from anywhere in the world. Time to confirm it works.
- Copy your Render service URL from the top of the service page in the Render Dashboard.
- Open a new browser tab and navigate to your API's tasks endpoint by visiting:
[[RENDER_URL="https://your-service-name.onrender.com"]]/api/tasks
- You should see a JSON response. If you still have tasks in your database from earlier testing, they will appear as a JSON array. If not, you will see an empty array [].
Why does the first request take a while?
Render's free tier spins down your service after 15 minutes of inactivity. The first request after a spin-down takes about 1 minute while the server wakes up. Subsequent requests are fast.
Seeing an error or blank page?
- Make sure you added /api/tasks to the end of your Render URL. The root path / does not have a route defined, so it will not return anything useful.
- Wait up to 60 seconds on the first load. The free tier server may be waking up.
- Check the Render Dashboard logs tab to see if any errors are being thrown at runtime.
Help me verify my live Render API
Your backend is officially live on the internet. Next up, you'll deploy the React frontend to Vercel and point it at this Render URL so users can interact with your Task Manager through a real interface.
Deploy the Frontend to Vercel
Your backend is live on Render and responding to API requests. Now you need to get the React frontend online so anyone can use your Task Manager from a browser.
In this step, you will push the frontend code to GitHub and deploy it on Vercel. Vercel auto-detects Vite projects and serves them from a global CDN with automatic SSL.
In this step, get ready to:
- Push the frontend code to a GitHub repository.
- Import the project on Vercel and configure the API environment variable.
- Verify the full-stack app works end-to-end via the live Vercel URL.
Push the frontend code to GitHub
Vercel deploys directly from a GitHub repository. You need to push your client/ code to GitHub so Vercel can pick it up.
- In your terminal, navigate into the client folder by running this command:
- Initialize a Git repository in the client folder by running these commands:
cd client
git init
git add .
git commit -m "Initial frontend commit"
What do these commands do?
- git init creates a new Git repository inside your client folder.
- git add . stages all the frontend files for commit.
- git commit saves a snapshot of these files so they can be pushed to GitHub.
- Go to github.com in your browser.
- Click the + icon in the top right, then select New repository.
- Name the repository mern-task-manager-client (or any name you prefer).
- Leave all other options at their defaults. Do not initialize with a README or .gitignore.
- Click Create repository.
- Copy the HTTPS URL shown on the next page.
- Back in your terminal, connect to GitHub and push by running these commands:
git remote add origin [[CLIENT_REPO_URL="https://github.com/YOUR_USERNAME/mern-task-manager-client.git"]]
git branch -M main
git push -u origin main
- Refresh the GitHub repository page. You should see all your frontend files listed (src/, package.json, vite.config.js).
Push failed or repository not found?
- Double check the remote URL matches the one GitHub showed you. It should end in .git.
- If you see an authentication error, make sure you are signed into Git on your machine. GitHub may prompt you to authenticate via a browser window.
- help me push my frontend code to GitHub
Import the project on Vercel
With your code on GitHub, Vercel can import it and deploy automatically on every push.
- Go to vercel.com and sign up or log in. Choose Continue with GitHub to connect your account.
- In the Vercel dashboard, click the Add New... dropdown button and select Project.
- You will see a list of your GitHub repositories. Find mern-task-manager-client and click Import next to it.
Don't see your repository?
If the repository is missing from the list, click Configure GitHub App and grant Vercel access to the repository. Then return to the import page and it should appear.
- On the configuration page, Vercel auto-detects the Framework Preset as Vite. Leave the defaults for project name, root directory, and build settings.
- Expand the Environment Variables section.
- In the Name field, enter VITE_API_URL.
- In the Value field, paste your Render backend URL (for example, https://your-service-name.onrender.com). Do not include a trailing slash.
- Click Deploy.
Why set VITE_API_URL here?
Vite requires environment variables to be prefixed with VITE_ to be exposed to the client bundle. Your App.jsx reads this variable with import.meta.env.VITE_API_URL to know where to send API requests.
In production, the frontend is on Vercel and the backend is on Render. They are on different domains, so the frontend needs the full backend URL.
Vercel will build your project and show a live deployment URL when it finishes. This takes about 30 to 60 seconds.
Build failed on Vercel?
- Check that your repository root contains package.json and vite.config.js. If they are inside a subfolder, set the Root Directory on Vercel to that subfolder.
- Make sure you committed all files (including index.html) before pushing.
- help me fix a failed Vercel build for my Vite React app
Verify the full-stack app end-to-end
Your frontend is now live. Time to confirm the entire pipeline works: Vercel frontend, Render backend, and MongoDB Atlas all talking to each other.
- On the Vercel deployment page, click the production URL (it looks like https://your-project.vercel.app) to open your live site.
- Type a task title (for example, Buy groceries) in the input field and click Add.
- Click the task title to toggle it as completed. A strikethrough should appear.
- Click Delete to remove the task.
- Refresh the page. Your tasks should persist because they are stored in MongoDB Atlas.
What if the first load is slow?
Render's free tier spins down your backend after 15 minutes of inactivity. The first request after a sleep period takes about 1 minute while the service wakes up. Subsequent requests will be fast.
Tasks not loading or seeing a network error?
- Open your browser's DevTools (F12) and check the Console tab for errors. A CORS error means your Render backend might not be running yet. Wait a minute and try again.
- Verify that the VITE_API_URL you set on Vercel matches your Render URL exactly, without a trailing slash.
- If you updated the environment variable after deploying, you need to redeploy. Go to your Vercel project, open Deployments in the sidebar, and trigger a new deployment.
- help me debug why my Vercel frontend can't reach my Render backend
You just shipped a full-stack MERN application to the internet. Your React frontend on Vercel communicates with your Express API on Render, which reads and writes data in MongoDB Atlas. Next up, a Secret Mission to add color-coded task categories.
Secret mission
Add Task Categories with Color-Coded Labels
Your task manager works great, but all tasks look the same. What if you could organize them by category and instantly filter to see only what matters right now? In this secret mission, you'll add a category system with color-coded badges and a filter dropdown.
Clean Up Your Resources
Clean Up Your Resources
All the services you used in this project run on free tiers, so there are no ongoing costs. Decide whether to keep your resources running or delete them entirely.
Resources you used:
- MongoDB Atlas M0 Cluster (free forever, no charges).
- Render Free Web Service (spins down after 15 minutes of inactivity, no charges).
- Vercel Hobby Deployment (free, no charges).
- GitHub repository (free, no charges).
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep building on your Task Manager or want it available as a live portfolio piece.
- All services are on free tiers. You will not be charged anything.
- The Render backend will spin down after 15 minutes of inactivity. It takes about 1 minute to wake up on the next request.
- Your MongoDB Atlas M0 cluster stays active indefinitely at no cost.
- Your Vercel deployment remains live and redeploys automatically when you push to GitHub.
Pause - I'll come back to this later
Since all services are on free tiers, there is nothing to pause. Your Render service automatically spins down after 15 minutes of inactivity and wakes up on the next request.
- No action needed. Everything stays free and idle until you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Delete the Render web service
- Open the Render Dashboard at dashboard.render.com.
- Click on your Task Manager web service to open it.
- Click Settings in the left sidebar.
- Scroll to the bottom of the Settings page and click Delete Web Service.
- Type the service name to confirm, then click Delete.
Delete the Vercel project
- Open the Vercel Dashboard at vercel.com/dashboard.
- Select your Task Manager project.
- Click Settings in the sidebar.
- Scroll to the bottom of the General page and find the Delete Project section.
- Click Delete, enter the project name to confirm, then click Continue.
Terminate the MongoDB Atlas cluster
- Open the MongoDB Atlas Dashboard at cloud.mongodb.com.
- Navigate to the Clusters page for your project (click Clusters under the Database heading in the sidebar).
- Click the ellipsis menu next to your cluster.
- Click Terminate from the dropdown menu.
- Enter the cluster name to confirm and click Terminate.
Delete the GitHub repository
- Open your repository on github.com.
- Click Settings in the top navigation bar.
- Scroll to the bottom of the page to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Delete local project files
- Remove the local project folder by running:
macOS
cd ~/Desktop
rm -rf mern-task-manager
Windows
cd ~\Desktop
rmdir /s /q mern-task-manager
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a full-stack MERN application from scratch.
You've learned how to:
- Built a React frontend and Express REST API that communicate over HTTP to create, read, update, and delete tasks.
- Connected to MongoDB Atlas for cloud-hosted database persistence using Mongoose schemas and models.
- Deployed the backend to Render and the frontend to Vercel with environment-based API configuration and automatic deploys from GitHub.
- Added task categories with color-coded badges and a filter dropdown for organizing tasks by type.
Ready to quiz yourself?