MERN Stack

Create a To-Do List App Using MERN Stack

By the Domain India teamPublished 8 min read
Knowledge base article
Contents (11 sections)

A to-do list is the classic first MERN project: small enough to finish in an afternoon, but it touches every layer, from a MongoDB collection through an Express API to a React interface. This guide builds one with current tools (Node.js LTS, Express 5, Mongoose, and React with Vite) and ends with how to put it online.

Key takeaways

Create two folders: server (Express 5 and Mongoose, exposing /api/todos for create, read, update and delete) and client (React created with Vite, calling the API with fetch). During development, Vite's proxy forwards /api to Express so you need no CORS setup. For production, build the React app and let Express serve it, so one Node.js app listens on the PORT variable. Keep the MongoDB connection string in an environment variable, never in code.

1. What you need

  • Node.js LTS (22 or 24) and npm. Check with node -v.
  • A MongoDB database: a free-tier cloud cluster from MongoDB Atlas, or MongoDB running locally (the official Docker image is the easiest way).
  • A code editor such as VS Code, and basic JavaScript and React knowledge.

Create React App, which older tutorials use, is deprecated. This guide uses Vite.

2. Project structure

text
mern-todo/
  server/
    index.js
    models/Todo.js
    routes/todos.js
    package.json
  client/
    src/App.jsx
    vite.config.js
    package.json

3. Set up the Express server

bash
mkdir -p mern-todo/server && cd mern-todo/server
npm init -y
npm install express mongoose

In package.json, add "type": "module" so you can use import, and a start script:

json
{
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env index.js",
    "start": "node index.js"
  }
}

Create .env (and add it to .gitignore):

text
MONGODB_URI=mongodb+srv://user:[email protected]/todos
PORT=3001

Node.js reads this file itself with --env-file, so you do not need the dotenv package.

4. The Todo model

js
// models/Todo.js
import mongoose from 'mongoose';

const todoSchema = new mongoose.Schema({
  task: { type: String, required: true, trim: true, maxlength: 200 },
  completed: { type: Boolean, default: false },
}, { timestamps: true });

export default mongoose.model('Todo', todoSchema);

required, trim and maxlength make Mongoose reject empty or oversized tasks, and timestamps adds createdAt and updatedAt for you.

5. API routes

js
// routes/todos.js
import { Router } from 'express';
import Todo from '../models/Todo.js';

const router = Router();

router.get('/', async (req, res) => {
  res.json(await Todo.find().sort({ createdAt: -1 }));
});

router.post('/', async (req, res) => {
  const todo = await Todo.create({ task: req.body.task });
  res.status(201).json(todo);
});

router.patch('/:id', async (req, res) => {
  const update = {};
  if (typeof req.body.task === 'string') update.task = req.body.task;
  if (typeof req.body.completed === 'boolean') update.completed = req.body.completed;
  const todo = await Todo.findByIdAndUpdate(req.params.id, update, { new: true, runValidators: true });
  if (!todo) return res.status(404).json({ error: 'Not found' });
  res.json(todo);
});

router.delete('/:id', async (req, res) => {
  const todo = await Todo.findByIdAndDelete(req.params.id);
  if (!todo) return res.status(404).json({ error: 'Not found' });
  res.status(204).end();
});

export default router;

Each route copies only the fields it accepts. Passing req.body straight to the database, as many older examples do, lets a client write any field it likes.

6. The server entry point

js
// index.js
import express from 'express';
import mongoose from 'mongoose';
import path from 'node:path';
import todosRouter from './routes/todos.js';

const app = express();
app.use(express.json());
app.use('/api/todos', todosRouter);

// In production, serve the built React app
const dist = path.resolve('../client/dist');
app.use(express.static(dist));
app.get('/{*splat}', (req, res) => res.sendFile(path.join(dist, 'index.html')));

// One place for errors
app.use((err, req, res, next) => {
  console.error(err);
  const status = err.name === 'ValidationError' || err.name === 'CastError' ? 400 : 500;
  res.status(status).json({ error: status === 400 ? err.message : 'Server error' });
});

await mongoose.connect(process.env.MONGODB_URI);
const port = process.env.PORT || 3001;
app.listen(port, '0.0.0.0', () => console.log(`API on port ${port}`));

Express 5 sends errors from async routes to the error middleware automatically, so the routes above need no try/catch. The /{*splat} pattern is Express 5 syntax for "any other path"; on Express 4 it was *.

Run it with npm run dev, then test with curl:

bash
curl -X POST localhost:3001/api/todos -H 'Content-Type: application/json' -d '{"task":"Buy milk"}'
curl localhost:3001/api/todos

7. Create the React client

From the mern-todo folder:

bash
npm create vite@latest client -- --template react
cd client && npm install

Tell Vite's development server to forward API calls to Express:

js
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { proxy: { '/api': 'http://localhost:3001' } },
});

Because the browser only ever talks to one origin, you do not need the cors package.

8. The to-do interface

Replace src/App.jsx:

jsx
import { useEffect, useState } from 'react';

async function api(path, options = {}) {
  const res = await fetch(`/api/todos${path}`, {
    headers: { 'Content-Type': 'application/json' },
    ...options,
  });
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  return res.status === 204 ? null : res.json();
}

export default function App() {
  const [todos, setTodos] = useState([]);
  const [task, setTask] = useState('');
  const [error, setError] = useState('');

  useEffect(() => {
    api('').then(setTodos).catch(e => setError(e.message));
  }, []);

  async function addTodo(e) {
    e.preventDefault();
    if (!task.trim()) return;
    const todo = await api('', { method: 'POST', body: JSON.stringify({ task }) });
    setTodos([todo, ...todos]);
    setTask('');
  }

  async function toggle(todo) {
    const updated = await api(`/${todo._id}`, {
      method: 'PATCH',
      body: JSON.stringify({ completed: !todo.completed }),
    });
    setTodos(todos.map(t => (t._id === updated._id ? updated : t)));
  }

  async function remove(id) {
    await api(`/${id}`, { method: 'DELETE' });
    setTodos(todos.filter(t => t._id !== id));
  }

  return (
    <main>
      <h1>To-do list</h1>
      {error && <p role="alert">{error}</p>}
      <form onSubmit={addTodo}>
        <input value={task} onChange={e => setTask(e.target.value)} placeholder="New task" />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map(todo => (
          <li key={todo._id}>
            <label>
              <input type="checkbox" checked={todo.completed} onChange={() => toggle(todo)} />
              {todo.task}
            </label>
            <button onClick={() => remove(todo._id)}>Delete</button>
          </li>
        ))}
      </ul>
    </main>
  );
}

Run npm run dev in client and open the address Vite prints. The built-in fetch is enough here; you do not need axios.

9. Testing

  • API: test the routes with Vitest or Jest plus Supertest, against a test database.
  • Interface: React Testing Library with Vitest checks that adding, ticking and deleting a task updates the list.
  • End to end: Playwright can drive a real browser through the whole flow.

10. Build for production and deploy

Build the client, then run only the server:

bash
cd client && npm run build   # creates client/dist
cd ../server && npm start    # serves the API and the built app

The app is now one Node.js process that listens on PORT and serves both /api/todos and the React pages. Set MONGODB_URI as an environment variable on the host rather than uploading .env.

11. Running this on Domain India

  • App Platform: the simplest fit for this app. Node.js apps are detected and built automatically; the app must listen on PORT and bind to 0.0.0.0, as the code above does. Add MONGODB_URI in the Env Vars tab. The platform expects package.json and a Procfile at the top of the uploaded project, so for this two-folder layout, build the client first and give the root a package.json and Procfile that start the server; Deploy your first app shows both files. The platform includes PostgreSQL rather than MongoDB, so use a hosted MongoDB service. See Getting started with App Platform.
  • VPS: self-managed with full root access, so you can run MongoDB, Node.js and Nginx on the same server. Follow Running MERN on a clean VPS.
  • Shared hosting: the Node.js part runs through the Node.js app tool in cPanel or DirectAdmin, but you cannot run a MongoDB server there. See Deploy a Node.js app on shared hosting, and ask support to confirm your server can reach your hosted database.
App Starter
₹100/mo + GST
  • 512 MB RAM per app
  • 1 vCPU
  • 5 GB NVMe SSD
  • PostgreSQL Database
See plan details
Should I use Create React App for a new MERN project?

No. Create React App is deprecated. Use Vite, which creates a React project with npm create vite@latest and starts much faster.

Do I need the cors package for a MERN app?

Not if the browser only talks to one origin. Use Vite's dev-server proxy during development, and let Express serve the built React app in production. You need CORS only when the front end and API are on different domains.

Do I need axios to call my API from React?

No. The browser's built-in fetch is enough for a to-do app. Check res.ok for errors, because fetch does not throw on 404 or 500 responses.

Where should I keep my MongoDB connection string?

In an environment variable such as MONGODB_URI. Locally, keep it in a .env file that is listed in .gitignore; on the server, set it in the host's environment settings.

Can I host a MERN to-do app on Domain India?

Yes. The App Platform runs the Node.js app with automatic builds, using a hosted MongoDB service for the database. A VPS lets you run MongoDB yourself. Shared hosting can run the Node.js part, but not a MongoDB server.

Why does my React app get a 404 on page refresh in production?

The server does not know the React route. Add a catch-all route in Express that returns index.html for any path that is not an API route or a static file, as in the entry-point example.

Ready to put your app online? Compare App Platform plans and VPS plans, or read the REST and MVC primer next.

Need help choosing where to deploy?

Tell us what your app does and how many users you expect, and we will suggest the App Platform, a VPS or shared hosting.

Ask our team

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
MERN To-Do App Tutorial: Express 5, Mongoose, React Vite