Connect Express to MongoDB with Mongoose

MERN Stack Full Course: MongoDB, Express, React and Node.js · MongoDB and Mongoose · 5 min read · Updated

Connect, then listen

  1. The connection string is a secret, so it lives in the .env file.
  2. On start, connectDB reads it and calls mongoose.connect.
  3. Mongoose opens a connection to the database. If this fails the app stops with a clear message.
  4. Only after a successful connection do we start listening for requests.
  5. This order means a visitor never reaches an API that has no database behind it.

Your server can now talk HTTP. Next it must talk to MongoDB. You could use the official driver, which sends raw commands, but most MERN projects use Mongoose, a library on top of the driver. Mongoose adds three things you will want: schemas (rules for what a document must look like), models (objects that have methods like find and create), and helpful validation and error messages. This lesson only makes the connection. The next three use it.

The connection string

# Copy this file to .env and fill it in. Never commit .env to Git.
PORT=5000
CLIENT_ORIGIN=http://localhost:5173
# Atlas: mongodb+srv://USER:PASSWORD@cluster0.xxxxx.mongodb.net/job_tracker
# Local: mongodb://127.0.0.1:27017/job_tracker
MONGODB_URI=mongodb://127.0.0.1:27017/job_tracker

A connection string is an address plus credentials plus the database name. Atlas gives you one starting with mongodb+srv://. A local one looks like mongodb://127.0.0.1:27017/job_tracker. The last part, after the final slash, is the database name; if it does not exist MongoDB creates it on first write. Put the real value in server/.env, and restart the server, because .env is not watched.

Connecting

import mongoose from 'mongoose';

export async function connectDB(uri = process.env.MONGODB_URI) {
  if (!uri) throw new Error('MONGODB_URI is missing. Copy .env.example to .env and set it.');
  mongoose.connection.on('disconnected', () => console.warn('MongoDB disconnected'));
  // serverSelectionTimeoutMS: fail in 8 seconds with a clear error instead of hanging for 30
  await mongoose.connect(uri, { serverSelectionTimeoutMS: 8000 });
  console.log(`MongoDB connected: ${mongoose.connection.name}`);
}

mongoose.connect(uri, options) returns a promise. We wrap it in a function so the main file stays short and so tests (lesson 24) can pass a different address. The option serverSelectionTimeoutMS: 8000 is a kindness to yourself: without it, a wrong address or a blocked IP makes the program hang for about 30 seconds before failing. With it you see the real error in 8 seconds. The disconnected listener logs when the connection drops, for example when your Wi-Fi does.

Start listening only when the database is ready

// Lesson 15: connect to MongoDB with Mongoose before we start listening.
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose';
import { connectDB } from './config/db.js';
import { logger } from './middleware/logger.js';
import { notFound, errorHandler } from './middleware/error.js';
import jobsRouter from './routes/jobs.js';

const app = express();
const port = Number(process.env.PORT) || 5000;

app.use(logger);
app.use(cors({ origin: process.env.CLIENT_ORIGIN || 'http://localhost:5173' }));
app.use(express.json({ limit: '100kb' }));

// readyState 1 means connected
app.get('/api/health', (req, res) => res.json({ ok: true, db: mongoose.connection.readyState === 1 ? 'connected' : 'not connected' }));
app.use('/api/jobs', jobsRouter);

app.use(notFound);
app.use(errorHandler);

try {
  await connectDB();
  app.listen(port, () => console.log(`API listening on http://localhost:${port}`));
} catch (err) {
  console.error('Could not start:', err.message);
  process.exit(1);
}

The server used to start with app.listen straight away. Now it first awaits connectDB() and only then listens, inside a try/catch. If the database is not available, the program prints a clear message and exits with process.exit(1), instead of accepting requests it cannot serve. This fail fast habit is much better than a server that is “up” but returns errors on every call. (Top-level await works here because the project uses ES modules.)

The health route now reports mongoose.connection.readyState. The value 1 means connected; 0 disconnected, 2 connecting, 3 disconnecting. Monitoring tools can call this route to know that the whole app, not only the web server, is healthy. In lesson 37 it will answer 503 when the database is down.

Common connection problems

  • “bad auth” or “Authentication failed”: the user name or password is wrong, or the password contains special characters that must be URL-encoded (@ becomes %40).
  • Timeout or “ECONNREFUSED”: for Atlas, your IP is not in Network Access; for local MongoDB, the service is not running.
  • It connects but you see no data in Atlas: you are looking at a different database name than the one in the string.

Your task

  1. Run npm install in server (it adds Mongoose) and put your connection string in server/.env as MONGODB_URI.
  2. Start the server and call /api/health. You should see db: connected.
  3. Break the string on purpose (wrong password). How long does it take to fail, and what does it print?
  4. Stop your local MongoDB (or remove your IP from Atlas) while the server runs and watch the log.

How to check it worked

  • The terminal prints “MongoDB connected: job_tracker”.
  • /api/health answers db: connected.
  • A wrong string makes the program exit within about 8 seconds with a clear message.

Summary

  • Mongoose adds schemas, models and validation on top of the MongoDB driver.
  • The connection string lives in .env; the part after the last slash is the database name.
  • Connect first, listen second, and exit with a clear message if the database is down.

Download the solution (mern-lesson-15.zip)

Frequently asked questions

Do I need Mongoose, or can I use the MongoDB driver directly?

You can use the driver directly. Mongoose adds schemas, validation, defaults and convenient model methods, which suits most web apps and keeps data consistent. Some teams prefer the plain driver for more control.

Why does my password with an @ sign fail?

In a connection string the @ separates the credentials from the host, so one inside the password confuses the parser. URL-encode special characters (@ becomes %40) or choose a password without them.

Why wait for the database before calling listen?

A server that accepts requests but has no database fails every call in confusing ways. Failing at start with a clear message is easier to notice and fix, and process managers such as pm2 can restart it.