MERN is a JavaScript stack for full web applications: MongoDB stores data, Express.js and Node.js run the API, and React builds the interface. This guide explains how the pieces fit and then builds a small, complete notes app with current versions: Express 5, Mongoose 9, React 19 and Vite.
What I tested: the API passes two integration tests against a real, in-memory MongoDB (mongodb-memory-server downloaded MongoDB 8.2.6), the React client type-checks and builds, and an end-to-end run with the API, the Vite preview server and headless Chrome showed notes created through the API rendering in the page.
The 2023 version of this post used Create React App (deprecated in 2025), React Router’s removed <Switch> component, and MongoDB connection options that current drivers ignore. Its code also stopped at placeholder comments. This version is a working app.
What Is the MERN Stack?
| Layer | Technology | Role |
|---|---|---|
| Database | MongoDB | Stores data as JSON-like documents; schemas are flexible |
| API | Express.js | Defines routes and middleware for the HTTP API |
| Runtime | Node.js | Runs JavaScript on the server |
| Interface | React | Builds the user interface in the browser |

The browser loads the React app, which calls the Express API over HTTP with JSON. Express validates the request, reads or writes MongoDB (here through Mongoose, which adds schemas and validation), and returns JSON. One language across the stack is the main attraction: shared skills, shared tooling and, with TypeScript, shared types.
MERN suits CRUD-heavy apps, dashboards and internal tools well. If most of your pages are content that rarely changes, a static or server-rendered approach such as the Jamstack may be a better fit, and if your data is highly relational, consider PostgreSQL instead of MongoDB.
Project Structure
notes-app/
server/ # Express + Mongoose API
src/
models/Note.js
routes/notes.js
app.js
index.js
test/notes.test.js
client/ # React + Vite
src/
api.ts
App.tsx
vite.config.tsYou need Node.js 20 or newer and a MongoDB database: a local install, a Docker container, or a free MongoDB Atlas cluster.
Step 1: Build the API
mkdir -p notes-app/server && cd notes-app/server
npm init -y
npm pkg set type=module
npm install express mongoose
npm install -D mongodb-memory-server supertestThe model
Mongoose schemas validate data before it reaches the database. timestamps adds createdAt and updatedAt automatically:
import mongoose from 'mongoose';
const noteSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true, maxlength: 200 },
body: { type: String, default: '', maxlength: 10_000 },
done: { type: Boolean, default: false },
},
{ timestamps: true }, // adds createdAt and updatedAt
);
export const Note = mongoose.model('Note', noteSchema);The routes
Express 5 passes errors thrown in async route handlers to the error middleware, so there is no try/catch boilerplate in each route. The router’s own error handler turns validation errors and malformed IDs into 400 responses instead of 500s:
import { Router } from 'express';
import mongoose from 'mongoose';
import { Note } from '../models/Note.js';
export const notes = Router();
// Express 5 forwards errors from async handlers to the error middleware automatically.
notes.get('/', async (req, res) => {
const list = await Note.find().sort({ createdAt: -1 }).limit(100);
res.json(list);
});
notes.post('/', async (req, res) => {
const { title, body } = req.body ?? {};
const note = await Note.create({ title, body });
res.status(201).json(note);
});
notes.patch('/:id', async (req, res) => {
const { title, body, done } = req.body ?? {};
const note = await Note.findByIdAndUpdate(
req.params.id,
{ title, body, done },
{ returnDocument: 'after', runValidators: true },
);
if (!note) return res.status(404).json({ error: 'Note not found' });
res.json(note);
});
notes.delete('/:id', async (req, res) => {
const note = await Note.findByIdAndDelete(req.params.id);
if (!note) return res.status(404).json({ error: 'Note not found' });
res.status(204).end();
});
// Turn Mongoose errors into proper HTTP responses.
notes.use((err, req, res, next) => {
if (err instanceof mongoose.Error.ValidationError) {
return res.status(400).json({ error: err.message });
}
if (err instanceof mongoose.Error.CastError) {
return res.status(400).json({ error: 'Invalid id' });
}
next(err);
});Two current details: Mongoose 9 deprecates the new: true option in favor of returnDocument: 'after', and runValidators makes updates obey the schema too.
The app and the entry point
Building the app in a function, separate from connecting and listening, lets tests use the same app without opening a port:
import express from 'express';
import { notes } from './routes/notes.js';
export function createApp() {
const app = express();
app.use(express.json({ limit: '100kb' }));
app.get('/api/health', (req, res) => res.json({ ok: true }));
app.use('/api/notes', notes);
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
return app;
}import mongoose from 'mongoose';
import { createApp } from './app.js';
const { MONGODB_URI, PORT = 3001 } = process.env;
if (!MONGODB_URI) throw new Error('Set MONGODB_URI');
await mongoose.connect(MONGODB_URI);
createApp().listen(PORT, () => console.log(`API on http://localhost:${PORT}`));MONGODB_URI=mongodb://127.0.0.1:27017/notes
PORT=3001Run it with node --env-file=.env src/index.js; Node loads the .env file itself, so you do not need the dotenv package. Note the port: I first used 5000, the default in many tutorials, and every request from the React app came back 403. On macOS, the AirPlay Receiver (ControlCenter) listens on port 5000, so pick another port such as 3001. More on this in avoiding local port conflicts.
Step 2: Test the API Against a Real MongoDB
mongodb-memory-server downloads and starts a real mongod for the tests, so you test actual queries and validation, not mocks:
import { after, before, beforeEach, test } from 'node:test';
import assert from 'node:assert/strict';
import mongoose from 'mongoose';
import request from 'supertest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createApp } from '../src/app.js';
import { Note } from '../src/models/Note.js';
let mongo;
const app = createApp();
before(async () => {
mongo = await MongoMemoryServer.create();
await mongoose.connect(mongo.getUri());
});
beforeEach(() => Note.deleteMany({}));
after(async () => {
await mongoose.disconnect();
await mongo.stop();
});
test('create, list, update and delete a note', async () => {
const created = await request(app).post('/api/notes').send({ title: ' Buy milk ', body: '2 litres' }).expect(201);
assert.equal(created.body.title, 'Buy milk'); // trimmed by the schema
assert.ok(created.body.createdAt);
const list = await request(app).get('/api/notes').expect(200);
assert.equal(list.body.length, 1);
const updated = await request(app).patch(`/api/notes/${created.body._id}`).send({ done: true }).expect(200);
assert.equal(updated.body.done, true);
assert.equal(updated.body.title, 'Buy milk'); // untouched fields kept
await request(app).delete(`/api/notes/${created.body._id}`).expect(204);
assert.equal((await request(app).get('/api/notes')).body.length, 0);
});
test('validation and bad ids return 400, missing notes 404', async () => {
await request(app).post('/api/notes').send({ body: 'no title' }).expect(400);
await request(app).patch('/api/notes/not-an-id').send({ done: true }).expect(400);
await request(app).delete(`/api/notes/${new mongoose.Types.ObjectId()}`).expect(404);
});$ npm test
✔ create, list, update and delete a note
✔ validation and bad ids return 400, missing notes 404
ℹ pass 2Step 3: Build the React Client
cd ..
npm create vite@latest client -- --template react-ts
cd client && npm installVite’s dev server can proxy /api requests to Express, so the browser talks to one origin and you do not need CORS during development:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
// Forward /api calls to Express during development, so there is no CORS to configure.
server: { proxy: { '/api': 'http://localhost:3001' } },
preview: { proxy: { '/api': 'http://localhost:3001' } },
});A small typed API module wraps fetch and surfaces the server’s error messages. For more on making requests, see how to make an HTTP request.
export type Note = { _id: string; title: string; body: string; done: boolean; createdAt: string };
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Request failed with ${res.status}`);
}
return (res.status === 204 ? undefined : await res.json()) as T;
}
export const api = {
list: () => request<Note[]>('/notes'),
create: (title: string, body = '') =>
request<Note>('/notes', { method: 'POST', body: JSON.stringify({ title, body }) }),
toggle: (note: Note) =>
request<Note>(`/notes/${note._id}`, { method: 'PATCH', body: JSON.stringify({ done: !note.done }) }),
remove: (id: string) => request<void>(`/notes/${id}`, { method: 'DELETE' }),
};The component lists, adds, toggles and deletes notes:
import { useEffect, useState, type FormEvent } from 'react';
import { api, type Note } from './api';
export default function App() {
const [notes, setNotes] = useState<Note[]>([]);
const [title, setTitle] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.list()
.then(setNotes)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
const add = async (e: FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
try {
const note = await api.create(title);
setNotes(n => [note, ...n]);
setTitle('');
} catch (err) {
setError((err as Error).message);
}
};
const toggle = async (note: Note) => {
const updated = await api.toggle(note);
setNotes(n => n.map(x => (x._id === updated._id ? updated : x)));
};
const remove = async (id: string) => {
await api.remove(id);
setNotes(n => n.filter(x => x._id !== id));
};
return (
<main>
<h1>Notes</h1>
<form onSubmit={add}>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="New note" aria-label="New note" />
<button type="submit">Add</button>
</form>
{error && <p role="alert">{error}</p>}
{loading ? (
<p>Loading…</p>
) : (
<ul>
{notes.map(note => (
<li key={note._id}>
<label>
<input type="checkbox" checked={note.done} onChange={() => toggle(note)} />
{note.done ? <s>{note.title}</s> : note.title}
</label>
<button onClick={() => remove(note._id)} aria-label={`Delete ${note.title}`}>Delete</button>
</li>
))}
</ul>
)}
</main>
);
}If you add multiple pages, install React Router and use <Routes> and <Route>; the <Switch> component from React Router 5 no longer exists.
Step 4: Run It
# terminal 1
cd server && node --env-file=.env src/index.js
# terminal 2
cd client && npm run devFor my end-to-end check I started the API against an in-memory MongoDB, ran vite preview with the same proxy, created two notes with curl through the proxy, and loaded the page in headless Chrome. The rendered list contained both notes, newest first.
Common Challenges and How to Handle Them
| Area | Challenge | What to do |
|---|---|---|
| Security | Unvalidated input, NoSQL injection, missing auth | Validate with schemas; never pass raw request objects into queries (Mongoose’s sanitizeFilter option helps); add authentication and authorization; set security headers |
| Errors | Async errors crashing or leaking stack traces | Central error middleware (built into Express 5 for async handlers); log details server-side, return generic messages |
| Data modeling | Treating MongoDB like a relational database | Model around how data is read; embed data read together, reference data shared widely; add indexes for your queries |
| Performance | Large bundles, unbounded queries | Paginate and limit queries, index fields you filter and sort on, let Vite split and minify the client |
| Deployment | Two apps, environment config, CORS | Serve the built client from the same domain (or configure CORS explicitly), keep secrets in environment variables, consider containers |
Where to Go Next
- Add authentication, for example sessions or a provider such as Firebase Authentication.
- Add pagination (
?page=withskip/limitor cursor-based) once lists grow. - Move to TypeScript on the server and share the
Notetype with the client. - Containerize both parts; see how to Dockerize a React app.
Conclusion
A modern MERN app is a small Express 5 API with Mongoose models and centralized error handling, a Vite React client that talks to it through a proxy in development, and tests that run against a real MongoDB. Keep validation in the schema, make the API return proper status codes, and test the whole path before adding features.
FAQs
What does MERN stand for?
MongoDB, Express.js, React and Node.js: a database, an API framework, a UI library and the JavaScript runtime that runs the server.
Is the MERN stack still relevant?
Yes for JavaScript teams building CRUD-heavy web apps and APIs. Many teams now swap pieces, for example Next.js instead of a separate React client, or PostgreSQL instead of MongoDB, depending on the app.
Should I use Mongoose or the MongoDB driver directly?
Mongoose adds schemas, validation and middleware, which suits most application code. The native driver is lighter and gives full control, which some teams prefer for performance-sensitive or schema-less work.
Why does my React app get a 403 from localhost:5000 on a Mac?
macOS uses port 5000 for the AirPlay Receiver. Run your API on another port, such as 3001, or turn off AirPlay Receiver in System Settings.
How do I avoid CORS errors in development?
Use the Vite dev server’s proxy so the browser calls /api on the same origin and Vite forwards it to Express. In production, serve the client and API from the same domain or configure CORS for your exact client origin.
How do I test a MERN API?
Run integration tests with supertest against your Express app and an in-memory MongoDB from mongodb-memory-server, so tests exercise real queries and validation without a shared database.








