What you'll learn

By the end of this tutorial you'll understand how MongoDB, Express, React, and Node.js fit together as a stack. You'll know what each layer is responsible for, how a request travels from the browser to the database and back, and how to structure a MERN project so every layer has a clear, maintainable boundary. You'll leave with a working scaffold and a mental map you can use while you're actually building.

This isn't abstract theory. The goal is a picture you can hold in your head while debugging at 11 p.m., not a slide deck.

Who this is for

  • Developers who've used one or two of these technologies separately and want to understand how they combine into a coherent system
  • Beginners who've heard "MERN stack" and want a clear explanation before committing weeks to learning it
  • Self-taught developers who've built something MERN-adjacent but feel like the overall architecture is still fuzzy

You can skip this if you've shipped a MERN app before and already have a clear mental model of how all four layers communicate. Come back when you're debugging a cross-layer issue and the root cause feels slippery — rereading the architecture often helps more than another Google search.

What is the MERN stack?

MERN is an acronym for four technologies that together cover everything a modern web application needs: MongoDB (database), Express (server framework), React (frontend UI library), and Node.js (the JavaScript runtime that runs Express). Every MERN app uses all four.

Plain English: MongoDB stores your data, Node.js and Express run a web server that reads and writes that data, and React shows it to users in the browser. The four pieces cover database, backend, and frontend — a complete stack without switching languages, because all four work with JavaScript (or TypeScript).

Simple idea: think of it as one language, JavaScript, running in two places — on the server (Node.js + Express + MongoDB client) and in the browser (React) — with plain HTTP connecting them.

Prerequisites

  • Comfortable with JavaScript: variables, functions, async/await, objects, arrays
  • Basic HTML and CSS — enough to understand what React is rendering into
  • Node.js installed locally (node -v should return 18 or higher)
  • A terminal and a code editor you're comfortable with

You don't need to know MongoDB or Express before this tutorial. Both are introduced as we go.

Setup from zero

Step 1 — Create the project folders

The most common MERN structure separates the backend and frontend into two folders inside one repository. Open your terminal and run:

mkdir my-mern-app
cd my-mern-app
mkdir server client

The server folder holds all Node.js + Express + Mongoose code. The client folder holds the React app. Two separate worlds inside one repo.

Step 2 — Initialise the server

cd server
npm init -y
npm install express mongoose cors dotenv
npm install --save-dev nodemon
  • express — the web framework that handles HTTP routes
  • mongoose — the MongoDB client with schema support
  • cors — lets the React frontend (running on a different port) call your API
  • dotenv — loads secrets and config from a .env file at startup

Add a dev script to server/package.json:

"scripts": {
  "dev": "nodemon index.js"
}

Step 3 — Initialise the React frontend

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

Vite is faster than Create React App and produces smaller bundles. The react-ts template gives you TypeScript from the start.

> Little tip: Keep server and client as separate npm workspaces with their own package.json files. Don't share dependencies between them — what runs in Node.js and what runs in the browser are genuinely different environments, and blurring that boundary causes subtle bugs.

The mental model

Think of a MERN app as a four-stage pipeline.

  1. MongoDB sits at the bottom, holding your data. It doesn't know what a browser is — it only talks to Node.js through the Mongoose driver.
  2. Node.js + Express sits above MongoDB. It listens for incoming HTTP requests, queries MongoDB for data, formats the results, and sends HTTP responses back.
  3. React runs in the browser. When it needs data, it sends HTTP requests to Express. When it gets data back, it renders it.
  4. HTTP is the wire connecting React to Express. Standard GET, POST, PUT, and DELETE requests over a URL — nothing proprietary, nothing magic.

Data flows: React → HTTP request → Express route → Mongoose query → MongoDB → Mongoose result → Express response body → React state update → re-render.

The most important architectural insight is that React and Express are completely separate. They don't share memory or function calls during runtime. The only connection is HTTP. This means you can swap React for any other frontend, or swap Express for any other server, without touching the opposite side.

Key terms

Route — a URL pattern in Express that maps to a handler function. GET /api/posts is a route.

Controller — the function that handles a route. It reads from the incoming request, calls the database via Mongoose, and writes the HTTP response.

Model — in Mongoose, a class that maps to a MongoDB collection. It defines the shape of your documents (schema) and provides query methods like find() and findById().

Middleware — a function in Express that runs before the controller. Used for logging, parsing request bodies, checking auth tokens, and validating input.

Component — a React function that returns JSX. The basic building block of the frontend. Components compose into pages.

State — data that lives inside a React component. When state changes, React re-renders the component that owns it.

API endpoint — a URL that your Express server exposes for React (or any client) to call. /api/posts is an endpoint.

Step-by-step: a complete data flow

Let's trace a real request — the browser loads a list of blog posts.

1. React component mounts and fetches

// client/src/components/PostList.tsx
import { useEffect, useState } from "react";

interface Post { _id: string; title: string; }

export function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("http://localhost:5000/api/posts")
      .then(r => r.json())
      .then(data => { setPosts(data); setLoading(false); });
  }, []);

  if (loading) return <p>Loading...</p>;
  return (
    <ul>
      {posts.map(p => <li key={p._id}>{p.title}</li>)}
    </ul>
  );
}

2. Express receives the request and queries MongoDB

// server/routes/posts.js
const express = require("express");
const router = express.Router();
const Post = require("../models/Post");

router.get("/", async (req, res) => {
  try {
    const posts = await Post.find().sort({ createdAt: -1 }).limit(20);
    res.json(posts);
  } catch (err) {
    res.status(500).json({ error: "Failed to fetch posts" });
  }
});

module.exports = router;

3. The Mongoose model defines the data shape

// server/models/Post.js
const mongoose = require("mongoose");

const postSchema = new mongoose.Schema(
  {
    title: { type: String, required: true, trim: true },
    body:  { type: String, required: true },
  },
  { timestamps: true }
);

module.exports = mongoose.model("Post", postSchema);

4. Express wires everything together at startup

// server/index.js
require("dotenv").config();
const express   = require("express");
const mongoose  = require("mongoose");
const cors      = require("cors");
const postRoutes = require("./routes/posts");

const app = express();
app.use(cors());
app.use(express.json());
app.use("/api/posts", postRoutes);

mongoose.connect(process.env.MONGO_URI)
  .then(() => app.listen(5000, () => console.log("Server on port 5000")));

> Little tip: Always prefix your API routes with /api. When you later deploy behind a reverse proxy or on a platform like Render, the /api prefix makes it trivial to route backend traffic separately from frontend static assets — a ten-second config change instead of an hour of debugging.

Patterns

Separation of concerns by folder — keep models/, routes/, and controllers/ as separate folders inside server/. Routes handle URL matching; controllers handle business logic; models handle data shape. Each folder answers one question.

Single useEffect per data fetch — in React, each data fetch should live in its own useEffect with the correct dependency array. Combining unrelated fetches in one effect makes loading states and error handling significantly harder to manage.

Environment variables for everything configurable — MongoDB URI, JWT secret, API base URL. Never hardcode them. Store them in .env, add .env to .gitignore, and read them with process.env on the server and import.meta.env in Vite.

Common mistakes

Forgetting CORS — React dev server typically runs on port 5173 or 3000 while Express runs on 5000. Browsers block cross-origin requests by default. The cors() middleware on your Express app fixes this. Forgetting it produces a confusing browser console error that looks like a network failure, not a configuration problem.

Sending two responses from one handler — once you call res.json(), Express has sent the response. Calling it again in a catch block after an early return causes a "headers already sent" crash. Every early error response must have return before it.

Putting business logic in route files — a route file should only match URLs and call controller functions. Mongoose queries, calculations, and validation belong in controller files. Keep route files thin; the moment a handler exceeds 10 lines, move the logic to a controller.

Troubleshooting

MongooseServerSelectionError on startup — Express can't reach MongoDB. Check that MONGO_URI is set in your .env file. If using MongoDB Atlas, check that your current IP address is in the network access allowlist under the Security section of the Atlas dashboard.

React fetch returns HTML instead of JSON — usually means Express isn't running or the URL port is wrong. Confirm your Express server is on port 5000 and your React fetch call uses http://localhost:5000, not http://localhost:5173.

Cannot read properties of undefined in React — the component rendered before the fetch completed and tried to access a property on undefined. Always initialise state with a safe default (useState([]) for lists, useState(null) for objects) and guard rendering with a loading or null check.

Checklist

  • [ ] server/ and client/ folders created with their own package.json
  • [ ] express, mongoose, cors, dotenv installed in server/
  • [ ] MongoDB connection string in .env, never hardcoded
  • [ ] cors() middleware applied before routes in index.js
  • [ ] express.json() middleware applied so req.body is available
  • [ ] React fetches from http://localhost:5000/api/...
  • [ ] Mongoose model schema includes at least one required field
  • [ ] At least one route returns real data from MongoDB

Practice task

Build a simple notes app from scratch using this architecture: one Mongoose model (Note with title and body), three Express routes (GET /api/notes, POST /api/notes, DELETE /api/notes/:id), and a React page that lists existing notes and includes a form to create a new one. No auth, no styling — just the four layers talking to each other cleanly. This is the scaffold for every MERN project that follows.

FAQ

Do React and Express have to run on different ports during development?

Yes — the React dev server and the Express server are separate processes. In production, you often serve React's built files from Express directly (one server, one port). During development, two separate servers on two ports is the standard setup — CORS handles the cross-origin requests.

Is MongoDB required or can I use a different database?

The M in MERN is MongoDB, but Express and Node.js work with any database — PostgreSQL, MySQL, SQLite. Swapping out MongoDB is entirely possible. The "MERN stack" specifically means MongoDB though; if you swap it, you're building a different stack with a different name.

Should I use TypeScript?

Yes, if you're starting fresh. TypeScript catches shape mismatches between your Mongoose models and your React components — exactly where MERN apps develop subtle bugs as they grow. The upfront configuration cost is about 15 minutes; the debugging time saved is much larger.

What to learn next

Once the four-layer mental model is clear, the natural next steps are: JWT authentication (how to protect routes and persist login across the stack), structured API design (organizing Express routes and controllers at scale), and deployment (getting the stack running on real servers, not localhost).

  • MERN auth basics — adding JWT authentication to the architecture introduced here
  • MERN API design — structuring Express routes, controllers, and middleware as the project grows
  • MongoDB modeling for MERN — designing Mongoose schemas that hold up under real data

Takeaways

The MERN stack has four layers, each with one job: MongoDB stores data, Express handles HTTP, React handles the UI, Node.js is the JavaScript runtime everything server-side depends on. The four layers connect through plain HTTP — standard requests and responses, nothing proprietary. Understanding this boundary is the most useful debugging skill you can develop for MERN work.

If you remember only one thing: React and Express talk only over HTTP during runtime. They share no memory, no function calls, no imports between them. Every piece of data React displays was sent as an HTTP response from Express. That boundary is the most important architectural fact in any MERN application.