This guide will walk you through building a full-stack MERN To-Do application. It covers setting up the environment, writing code to demonstrate core CRUD (Create, Read, Update, Delete) operations, and connecting the application to MongoDB Atlas, a free cloud database.
Before diving into this article, I recommend that you have a foundational understanding of HTML, CSS, and JavaScript, as well as some knowledge of frontend and backend frameworks and libraries.
My primary focus will be on functionality, allowing you to customize the design as you see fit. The commands I’ll use here are tailored for Windows, so if you’re using Linux, macOS, or Ubuntu, you may need to adjust them accordingly.
By the end of this guide, you’ll have a fully functional To-Do app up and running on your system.
Table of Contents
Introduction to the MERN Stack
The MERN stack is a popular JavaScript stack for building modern web applications. It consists of:
-
MongoDB: A NoSQL database for storing data.
-
Express.js: A backend framework for building APIs.
-
React (UI library) + Vite (build tool) + TypeScript (typed JavaScript): A modern frontend stack for building scalable and maintainable user interfaces.
-
Node.js: A runtime environment for executing JavaScript on the server.
How to Set Up Your Development environment
Install Node.js and npm (Node Package Manager)
Instead of installing Node.js and npm in your project folder, I advise you to install them in your system’s root directory so that you can use them in any project, not just this one.
First, download and install Node.js (which includes npm) from the official website if you don’t have it already.
After installation, open your command line (I am using Git Bash) and verify the installation by running the following commands:
node -v
npm -v
You should see the installed versions of Node.js and npm if correctly installed.
How to Set Up a New MERN Project
Create a project folder and open your code editor by running these commands:
mkdir mern-todo-app
cd mern-todo-app
code .
The command code .
automatically opens VS Code. If it doesn’t, open VS Code manually and navigate to your mern-todo-app
folder.
Frontend Setup
Set Up Vite with React and TypeScript
Make sure you are in your project root directory (mern-todo-app
), then run the following command:
npm create vite@latest frontend --template react-ts
This command will create a TypeScript-based React frontend inside the frontend
folder within your mern-todo-app
directory.
Install Axios for Making API Requests
Axios is a popular JavaScript library used to make HTTP requests from the frontend to a backend API. It simplifies sending GET, POST, PUT, and DELETE requests and handling responses.
To install Axios, run the following command:
cd frontend
npm install axios
Build the To-Do App UI
Inside the src
folder, create an App.tsx
file if it doesn’t already exist, and add the below code. It’s a lot, but don’t worry – I’ll break it down bit by bit afterwards:
frontend/src/App.tsx
:
import React, useState, useEffect from "react";
import axios from "axios";
import TodoList from "./components/TodoList.tsx";
import "./App.css";
interface Task
_id: string;
title: string;
completed: boolean;
const App: React.FC = () => null>(null);
const [editingTitle, setEditingTitle] = useState<string>("");
useEffect(() =>
const fetchTasks = async () =>
try
const response = await axios.get<Task[]>(`http://localhost:5000/api/tasks`);
console.log("Fetched tasks:", response.data);
setTasks(response.data);
catch (error)
console.error("Error fetching tasks:", error);
;
fetchTasks();
, []);
const addTask = async () =>
if (!task) return;
try
console.log("Adding task:", task);
const response = await axios.post<Task>(
`http://localhost:5000/api/tasks`,
title: task ,
headers: "Content-Type": "application/json"
);
console.log("Task added response:", response.data);
setTasks([...tasks, response.data]);
setTask("");
catch (error)
console.error("Error adding task:", error);
;
const deleteTask = async (id: string) =>
try
await axios.delete(`http://localhost:5000/api/tasks/$id`);
setTasks(tasks.filter((t) => t._id !== id));
catch (error)
console.error("Error deleting task:", error);
;
const updateTask = async (id: string, updatedTask: Partial<Task>) =>
try
const response = await axios.put(
`http://localhost:5000/api/tasks/$id`,
updatedTask,
headers: "Content-Type": "application/json"
);
setTasks(
tasks.map((task) =>
task._id === id ? ...task, ...response.data : task
)
);
setEditingTaskId(null);
setEditingTitle("");
catch (error)
console.error("Error updating task:", error);
;
const startEditing = (id: string) =>
setEditingTaskId(id);
;
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setEditingTitle(e.target.value);
;
return (
<div className="App">
<h1>Todo App</h1>
<div>
<input
type="text"
value=task
onChange=(e) => setTask(e.target.value)
/>
<button onClick=addTask>Add Task</button>
</div>
<TodoList
tasks=tasks
deleteTask=deleteTask
updateTask=updateTask
editingTitle=editingTitle
setEditingTitle=setEditingTitle
editingTaskId=editingTaskId
setEditingTaskId=setEditingTaskId
startEditing=startEditing
handleEditChange=handleEditChange
/>
</div>
);
;
export default App;
Here’s a block-by-block breakdown of the code above:
BLOCK 1: Importing dependencies
-
React, useState, useEffect
: Manages component state and side effects. -
axios
: Handles API requests. -
TodoList.tsx
: A child component to display and manage tasks. -
App.css
: Styles the app.
BLOCK 2: Defining the task interface
- Defines the structure of a task (
_id
,title
,completed
).
BLOCK 3: Setting up state variables
-
tasks
: Stores the list of tasks. -
task
: Holds input for new tasks. -
editingTaskId
: Tracks the task being edited. -
editingTitle
: Stores the updated title while editing.
BLOCK 4: Fetching tasks from the backend (useEffect
)
-
Runs once when the app loads.
-
Calls the API (
GET /api/tasks
) to get tasks and updatestasks
. -
Error handling**:** Logs an error message if the fetching request fails
BLOCK 5: Adding a task
-
Sends a
POST
request to add a new task. -
Updates
tasks
with the new task. -
Error handling**:** Logs an error message if the adding task request fails
BLOCK 6: Deleting a task
-
Sends a
DELETE
request to remove a task. -
Updates
tasks
by filtering out the deleted task. -
Error handling**:** Logs an error message if the deleting task request fails
BLOCK 7: Updating a task
-
Sends a
PUT
request to update a task’s title. -
Updates
tasks
with the new title. -
Error handling**:** Logs an error message if the update request fails
BLOCK 8: Handling edits
BLOCK 9: Rendering the UI
-
Displays an input field and button to add tasks.
-
Passes task data and functions (
deleteTask
,updateTask
, etc.) toTodoList.tsx
.
BLOCK 10: Exporting the component
export default App;
: MakesApp
usable in other files.
Displaying your tasks in the UI
Inside the src
folder, create a new folder named components
. Then add a TodoList.tsx
file inside it with the below code.
src/components/TodoList.tsx
:
import React from "react";
interface Task
_id: string;
title: string;
completed: boolean;
interface TodoListProps
tasks: Task[];
deleteTask: (id: string) => void;
updateTask: (id: string, updatedTask: Partial<Task>) => void;
editingTitle: string;
setEditingTitle: (title: string) => void;
editingTaskId: string
const TodoList: React.FC<TodoListProps> = (
tasks,
deleteTask,
updateTask,
editingTitle,
setEditingTitle,
editingTaskId,
setEditingTaskId,
startEditing,
handleEditChange,
) => {
return (
<ul>
{tasks.map((task) => (
<li key=task._id>
<input
type="checkbox"
checked=task.completed
onChange=() => updateTask(task._id, completed: !task.completed )
/>
editingTaskId === task._id ? (
<>
<input type="text" value=editingTitle onChange=handleEditChange />
<button
onClick=() =>
updateTask(task._id, title: editingTitle );
setEditingTaskId(null);
>
Save
</button>
</>
) : (
<>
<span style= textDecoration: task.completed ? "line-through" : "none" >
task.title
</span>
<div>
<button onClick=() => deleteTask(task._id)>Delete</button>
<button
onClick=() =>
startEditing(task._id);
setEditingTitle(task.title);
>
Edit
</button>
</div>
</>
)
</li>
))}
</ul>
);
};
export default TodoList;
Here’s a block-by-block breakdown of the code above:
BLOCK 1: Importing dependencies
- React: Enables functional component creation.
BLOCK 2: Defining interfaces
-
Task interface: Defines
_id
,title
, andcompleted
properties. -
TodoListProps interface: Defines props passed to the
TodoList
component
BLOCK 3: Declares the TodoList
component
BLOCK 4: Rendering the Task List and handling task actions
-
Maps through
tasks
to display each task inside a<ul>
. -
Checkbox toggles
completed
status usingupdateTask()
. -
Conditional rendering:
-
If a task is being edited, an input field appears for editing.
-
Otherwise, the task title is displayed with a strikethrough if completed
-
-
Save button: Updates the task title using
updateTask()
, then exits edit mode. -
Delete button: Calls
deleteTask()
to remove a task. -
Edit button: Enables edit mode, setting
editingTaskId
andeditingTitle
.
BLOCK 5: Exporting the component
- Makes
TodoList
available for use in other components.
Give Your App Customized Styling
Inside your src
folder, create App.css
if it doesn’t exist and replace the content with your desired styling. Let’s give the frontend a finishing touch.
src/App.css
:
html, body
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: Arial, sans-serif;
background-color: #f4f4f4;
width: 100%;
overflow-x: hidden;
.App
text-align: center;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 350px;
box-sizing: border-box;
h1
margin-bottom: 20px;
font-size: 24px;
color: #333;
input
width: 100%;
padding: 8px;
margin-bottom: 10px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
button
width: 100%;
padding: 10px;
margin-top: 5px;
border: none;
background-color: #007bff;
color: white;
cursor: pointer;
border-radius: 5px;
font-size: 14px;
transition: background-color 0.3s ease-in-out;
button:hover
background-color: #0056b3;
ul
list-style: none;
padding: 0;
margin: 0;
li
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
padding: 10px;
margin: 5px 0;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
width: 100%;
box-sizing: border-box;
span
flex: 1;
font-size: 16px;
color: #333;
span.completed
text-decoration: line-through;
color: #888;
input[type="text"]
width: 70%;
padding: 8px;
border-radius: 5px;
border: 1px solid #ccc;
input[type="checkbox"]
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #007bff;
margin-right: 10px;
.editing-container
display: flex;
align-items: center;
gap: 10px;
width: 100%;
@media (max-width: 400px)
.App
width: 95%;
padding: 15px;
max-width: none;
li
flex-direction: column;
align-items: flex-start;
input
width: 100%;
button
width: 100%;
padding: 10px;
Here’s what this CSS code does:
First, it centers the app (html, body
):
-
Uses
flexbox
to center the app vertically and horizontally. -
Sets
height: 100vh
for full-screen height. -
Prevents horizontal scrolling with
overflow-x: hidden
.
Then it styles the main app container (.App
):
Next, we handle typography and layout:
-
Sets
Arial, sans-serif
as the font. -
Adds spacing below the title (
h1
). -
Ensures task text takes available space with
span flex: 1;
.
Then we deal with input and button styling:
-
Inputs are full-width, styled with padding, borders, and rounded corners.
-
Buttons are blue, full-width, with hover effects (
background-color: #0056b3
).
And then task list styling (ul, li, span.completed
):
-
Removes default list styles.
-
Each task (
li
) has a white background, padding, rounded corners, and a shadow. -
Completed tasks are styled with a
line-through
and faded text color.
Next, we handle checkbox and editing mode styling:
And finally, we make the design responsive (@media (max-width: 400px)
):
Backend Setup
In your VS Code terminal, make sure you are in your project root directory (inside mern-todo-app
) and then create a folder called backend
. Navigate to the backend
folder and initialize Node.js
:
mkdir backend
cd backend
npm init -y
Install Dependencies
Still inside your backend
folder, run this command:
npm install express mongoose dotenv cors
In this command,
-
express
is a fast and minimal web framework for Node.js used to create server-side applications and APIs. -
mongoose
is an Object Data Modeling (ODM) library for MongoDB, simplifying database interactions. -
dotenv
loads environment variables from a.env
file, keeping sensitive data secure. -
cors
enables Cross-Origin Resource Sharing, allowing frontend applications to communicate with the backend across different domains.
Create a server.js File
Inside your backend
folder, create a file named server.js
and enter the following code:
backend/server.js
:
const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");
const dotenv = require("dotenv");
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
const connectDB = async () =>
try
await mongoose.connect(process.env.MONGO_URI);
console.log("MongoDB Connected");
catch (err)
console.error("MongoDB Connection Failed:", err);
process.exit(1);
;
connectDB();
const tasksRoutes = require("./routes/tasks");
app.use("/api/tasks", tasksRoutes);
const PORT = process.env.PORT || 5000;
app.listen(PORT, () =>
console.log(`Server running on port $PORT`);
);
Here’s a block-by-block breakdown of the code above:
BLOCK 1: Importing dependencies
-
express**:** Creates the server.
-
mongoose**:** Connects to MongoDB.
-
cors**:** Enables cross-origin requests.
-
dotenv**:** Loads environment variables.
BLOCK 2: Configuring the Express app
BLOCK 3: Setting up middleware
BLOCK 4: Connecting to MongoDB
BLOCK 5: Defining routes
BLOCK 6: Starting the server
Define Task Model
In your backend
folder, create a model
folder. Inside model
, create a file named Task.js
and add the following code:
backend/model/Task.js
:
const mongoose = require("mongoose");
const TaskSchema = new mongoose.Schema(
title: type: String, required: true ,
completed: type: Boolean, default: false ,
);
module.exports = mongoose.model("Task", TaskSchema);
Here’s a block-by-block breakdown of the code above:
BLOCK 1: Importing Mongoose
mongoose
: Used to define the schema and interact with MongoDB.
BLOCK 2: Defining the task schema
BLOCK 3: Creating and exporting the model
Define Routes
In your backend
folder, create a routes
folder. Inside routes
, create a file named tasks.js
and add the following code:
backend/routes/tasks.js
:
const express = require("express");
const Task = require("../models/Task");
const router = express.Router();
router.get("/", async (req, res) =>
try
const tasks = await Task.find();
res.json(tasks);
catch (err)
res.status(500).json( error: err.message );
);
router.post("/", async (req, res) =>
const title = req.body;
console.log("Received title:", title);
if (!title)
return res.status(400).json( error: "Task title is required" );
try
const newTask = new Task( title );
await newTask.save();
res.status(201).json(newTask);
catch (err)
console.error(err.message);
res.status(500).json( error: err.message );
);
router.delete("/:id", async (req, res) =>
try
const id = req.params;
await Task.findByIdAndDelete(id);
res.json( message: "Task deleted" );
catch (err)
res.status(500).json( error: err.message );
);
router.put("/:id", async (req, res) =>
try
const id = req.params;
const updatedTask = await Task.findByIdAndUpdate(
id,
req.body,
new: true
);
res.json(updatedTask);
catch (error)
res.status(500).json( error: "Error updating task" );
);
module.exports = router;
Here’s a block-by-block breakdown of the code above:
BLOCK 1: Import dependencies
-
express: Handles routing.
-
Task: Imports the Task model.
-
express.Router(): Creates a router for task-related routes.
BLOCK 2: GET all tasks
-
Fetches all tasks from the database.
-
Sends the tasks as a JSON response.
-
Handles errors with a 500 status.
BLOCK 3: POST a new task
-
Extracts
title
from the request body. -
Logs the received title for debugging.
-
Validates the title (returns 400 if missing).
-
Saves the task to the database and returns it.
BLOCK 4: DELETE a task
-
Extracts
id
from the request params. -
Deletes the task from the database.
-
Returns a success message.
BLOCK 5: UPDATE a task
-
Extracts
id
from the request params. -
Updates the task using request body data.
-
Returns the updated task.
BLOCK 6: Export the router
- Exports
router
for use in other parts of the app.
This Express.js router handles CRUD operations for a Task
model using MongoDB. It defines routes to get all tasks, add a new task, delete a task by ID, and update a task’s title by ID. Error handling ensures proper responses for missing data or server issues.
Create a .env file
In your backend folder, create a .env
file and add the following:
backend/.env
:
MONGO_URI=your_mongodb_atlas_uri
Set Up MongoDB Atlas
MongoDB Atlas is a cloud-based MongoDB service. We’ll use the free tier for this project.
To get started, go to MongoDB Atlas and create an account or log in.
Follow the steps to create a free cluster. Once the cluster is created, click Connect and follow the instructions to:
Replace the your_mongodb_atlas_uri
in .env
file with your MongoDB Atlas connection string.
If you are still not comfortable with how to set up MongoDB atlas, read this: MongoDB Atlas Tutorial – How to Get Started.
Run the Application
To run the application successfully using npm run dev
, you need to install a dependency that will start both the frontend and backend simultaneously. You can do this using concurrently.
Install concurrently:
Open your terminal, navigate to your project root directory (mern-todo-app
), and run:
npm install concurrently
Configure package.json
:
After installing concurrently, ensure that you have a package.json
file in your project root directory. If it doesn’t exist, create one and add the following code:
mern-todo-app/package.json
:
"name": "mern-todo-app",
"version": "1.0.0",
"private": true,
"scripts":
"start": "cd backend && npm start",
"client": "cd frontend && npm run dev",
"dev": "concurrently \"npm run start\" \"npm run client\""
,
"dependencies":
"concurrently": "^8.2.2"
This package.json
file configures the application by defining:
-
Project metadata (
name, version, private flag
). -
Scripts (
start
,client
, anddev
) to start the backend, run the frontend, and execute both simultaneously. -
Dependencies, including
concurrently
, which enables running multiple scripts in parallel. -
The project is set to private to prevent accidental publishing.
Start the Application
Ensure everything is set up and saved, then run the following command from the project root:
npm run dev
If the application starts successfully, you should see messages like:
Server running on port 5000
MongoDB Connected
View the Application
Open your browser and navigate to:
Test the functionality by adding, editing, saving, deleting tasks, and checking off completed tasks to ensure everything works properly.
Conclusion
Congratulations! You have successfully built a MERN To-Do app. You can further enhance it by adding features such as time and date tracking, and deploying it to a cloud platform.
Feel free to copy the code or clone the GitHub repository to add more functionalities and customize the styling to your preference. If you found this guide helpful, please consider sharing it and connecting with me!
For more learning resources: