Wednesday, March 5, 2025

How to Build a MERN Stack To-Do App

Programming LanguageHow to Build a MERN Stack To-Do App


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 updates tasks.

  • 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.) to TodoList.tsx.

BLOCK 10: Exporting the component

  • export default App;: Makes App 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, and completed 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 using updateTask().

  • 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 and editingTitle.

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, and dev) 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:

Check out our other content

Check out other tags:

Most Popular Articles