Scientyfic World

MERN Stack Tutorial: Build a Full App With MongoDB, Express, React and Node

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

Share:

Get an AI summary of this article

MERN Stack feature image

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?

LayerTechnologyRole
DatabaseMongoDBStores data as JSON-like documents; schemas are flexible
APIExpress.jsDefines routes and middleware for the HTTP API
RuntimeNode.jsRuns JavaScript on the server
InterfaceReactBuilds the user interface in the browser
Architectural structure of a MERN stack application

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.ts

You 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 supertest

The 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=3001

Run 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 2

Step 3: Build the React Client

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

Vite’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 dev

For 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

AreaChallengeWhat to do
SecurityUnvalidated input, NoSQL injection, missing authValidate with schemas; never pass raw request objects into queries (Mongoose’s sanitizeFilter option helps); add authentication and authorization; set security headers
ErrorsAsync errors crashing or leaking stack tracesCentral error middleware (built into Express 5 for async handlers); log details server-side, return generic messages
Data modelingTreating MongoDB like a relational databaseModel around how data is read; embed data read together, reference data shared widely; add indexes for your queries
PerformanceLarge bundles, unbounded queriesPaginate and limit queries, index fields you filter and sort on, let Vite split and minify the client
DeploymentTwo apps, environment config, CORSServe 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= with skip/limit or cursor-based) once lists grow.
  • Move to TypeScript on the server and share the Note type 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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts