MongoDB with Express and Next.js

Advanced
14 min

MongoDB with Express and Next.js

The driver and Mongoose lessons covered how to talk to MongoDB; this one covers where that code lives in a real web application. A long-running Express server and a Next.js app with hot reloading and serverless deployment have different connection lifecycles, and getting them wrong shows up as connection storms or "too many connections" errors in Atlas. After this lesson you will be able to structure database access for an Express API, share one client safely in Next.js, query from route handlers and Server Components, and configure pools for serverless platforms.

Express: Connect Once, Then Listen

Connect before accepting traffic so a broken database configuration fails at startup rather than on the first request, and close the connection on shutdown:

javascript
// server.js import express from "express"; import mongoose from "mongoose"; import { productRouter } from "./routes/products.js"; const app = express(); app.use(express.json()); app.use("/api/products", productRouter); app.use((err, req, res, next) => { // central error mapping if (err.name === "CastError") return res.status(400).json({ error: "Invalid id" }); if (err.name === "ValidationError") return res.status(400).json({ error: err.message }); if (err.code === 11000) return res.status(409).json({ error: "Duplicate value" }); res.status(err.status ?? 500).json({ error: "Server error" }); }); await mongoose.connect(process.env.MONGODB_URI, { autoIndex: false, maxPoolSize: 20 }); const server = app.listen(3000); process.on("SIGTERM", async () => { server.close(); await mongoose.disconnect(); process.exit(0); });

Routers then use the models directly; the error middleware turns Mongoose and driver errors into proper HTTP status codes:

javascript
// routes/products.js router.get("/:id", async (req, res, next) => { try { const product = await Product.findById(req.params.id).lean(); if (!product) return res.status(404).json({ error: "Not found" }); res.json(product); } catch (err) { next(err); } });

Next.js: Cache the Client Across Hot Reloads

In development Next.js re-evaluates modules on every change. A module-level new MongoClient() would create a fresh client (and pool) each time, so the sample code stores the instance on globalThis, where it survives reloads. In production the module runs once per server process anyway. The same idea applies to Mongoose:

typescript
// lib/db.ts import mongoose from "mongoose"; const g = globalThis as unknown as { _mongoose?: Promise<typeof mongoose> }; export function dbConnect() { g._mongoose ??= mongoose.connect(process.env.MONGODB_URI!, { bufferCommands: false }); return g._mongoose; }

Put MONGODB_URI in .env.local (never with a NEXT_PUBLIC_ prefix) and import these modules only from server code: route handlers, Server Components, Server Actions and middleware-free server utilities.

Route Handlers, Server Components and Server Actions

Server Components can query the database directly, without an API layer:

tsx
// app/products/page.tsx import { db } from "@/lib/mongodb"; import { ProductList } from "./ProductList"; // a client component export default async function ProductsPage() { const docs = await db.collection("products").find().sort({ name: 1 }).limit(50).toArray(); const products = docs.map(d => ({ id: d._id.toString(), name: d.name, price: d.price })); return <ProductList products={products} />; }

The mapping step matters: ObjectId, Decimal128 and Date are not serialisable as props for client components, so convert them to strings and numbers first. For writes, a Server Action or a POST route handler validates the body, calls the database and then revalidatePath("/products") so cached pages refresh.

Serverless and Edge Considerations

On platforms that run each request in a short-lived function (Vercel, AWS Lambda), every warm instance holds its own pool. Keep pools small — maxPoolSize: 5 to 10 — reuse the client at module scope so warm invocations share it, and set serverSelectionTimeoutMS low enough to fail fast. Atlas enforces a connection limit per cluster tier, so hundreds of cold starts with the default pool size of 100 can exhaust it.

Common Mistakes

  • Creating a client inside a request handler, exhausting connections under load.
  • Importing the database module into a client component, which leaks the connection string into the browser bundle or fails to build.
  • Passing raw documents to client components without converting _id and dates.
  • Listening before connecting in Express, so the first requests race the connection.
Quick Quiz
Question 1 of 3

Why is the `MongoClient` stored on `globalThis` in a Next.js project?

Key Takeaways

  • In Express, connect before listen(), share the connection through models or a module, and map database errors in one error middleware.
  • In Next.js, cache the client or Mongoose connection on globalThis so hot reloads do not multiply connections.
  • Route handlers, Server Components and Server Actions can query MongoDB directly; convert ObjectId and Date values before sending them to client components.
  • Keep MONGODB_URI server-only and out of NEXT_PUBLIC_ variables.
  • On serverless platforms use small pools, module-scope clients and fast timeouts.

Next lesson: Authentication, Users and Role-Based Access Control — lock the deployment down with users, roles and encryption.

MongoDB with Express and Next.js - MongoDB | CodeYourCraft | CodeYourCraft