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.
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
mern-todo/
server/
index.js
models/Todo.js
routes/todos.js
package.json
client/
src/App.jsx
vite.config.js
package.json3. Set up the Express server
mkdir -p mern-todo/server && cd mern-todo/server
npm init -y
npm install express mongooseIn package.json, add "type": "module" so you can use import, and a start script:
{
"type": "module",
"scripts": {
"dev": "node --watch --env-file=.env index.js",
"start": "node index.js"
}
}Create .env (and add it to .gitignore):
MONGODB_URI=mongodb+srv://user:[email protected]/todos
PORT=3001Node.js reads this file itself with --env-file, so you do not need the dotenv package.
4. The Todo model
// 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
// 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
// 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:
curl -X POST localhost:3001/api/todos -H 'Content-Type: application/json' -d '{"task":"Buy milk"}'
curl localhost:3001/api/todos7. Create the React client
From the mern-todo folder:
npm create vite@latest client -- --template react
cd client && npm installTell Vite's development server to forward API calls to Express:
// 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:
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:
cd client && npm run build # creates client/dist
cd ../server && npm start # serves the API and the built appThe 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
PORTand bind to0.0.0.0, as the code above does. AddMONGODB_URIin the Env Vars tab. The platform expectspackage.jsonand aProcfileat the top of the uploaded project, so for this two-folder layout, build the client first and give the root apackage.jsonandProcfilethat 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.
- 512 MB RAM per app
- 1 vCPU
- 5 GB NVMe SSD
- PostgreSQL Database
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.
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