Blog

NextJS Examples | MongoDB

Key Takeaways

  • Keep MongoDB access on the server and reuse a Mongoose connection pool within each running process.
  • For an existing custom Express server, await database readiness before accepting requests.
  • Preserve the asynchronous posts query, but add pagination, explicit field selection, and safe error responses.
  • You don't need Express just to access MongoDB: Pages Router API routes and App Router route handlers can do that directly.

A Next.js application can query MongoDB through Mongoose using a shared server-side connection helper. In a legacy Pages Router application with a custom Express server, Express handles the database endpoint and forwards other requests to Next.js.

This continues the Next.js custom Express server example. It's a maintenance guide for that architecture, not a recommendation to introduce a custom server: Next.js already provides server-side API routing.

Scope and Supported Versions

The examples target the following major-version baseline. Use maintained patch releases within these branches and commit your lockfile; these aren't claims about the latest individual releases.

ComponentExample baselineWhat matters here
Node.js24 LTSA supported LTS runtime for the server.
Next.js16, Pages RouterRetains the existing custom-server arrangement.
Express5Uses its supported routing and asynchronous error handling.
Mongoose9Uses the current connection API without obsolete parser options.

Check the Node.js release schedule, Next.js 16 upgrade guide, and Mongoose 9 migration guide before upgrading an older project. Mongoose 9 requires Node.js 20.19.0 or higher; that minimum isn't a recommendation to deploy an unsupported runtime.

Why Use Mongoose?

Mongoose is an object document mapper: it gives your MongoDB documents application-level schemas, validation, models, and middleware. A model is the interface you use to create and query documents with a particular schema.

Use it when those conventions simplify your application. Prefer the native MongoDB driver when you want direct driver APIs without Mongoose's model layer. Neither choice replaces input validation, authorization, or thoughtful database design; our Mongoose versus native driver comparison explores that trade-off.

The Setup: Keep MongoDB on the Server

Start inside an existing Next.js 16 project with its React dependencies already installed. Add the server dependencies:

npm install express@5 mongoose@9 @next/env@16

These examples use ECMAScript modules. Merge the following fields into your existing package.json; don't replace its dependencies. If your server files use require() and module.exports, migrate them together rather than mixing module conventions. The Node.js package documentation explains how "type": "module" affects JavaScript files.

{
  "type": "module",
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

The production script uses POSIX shell syntax. On Windows, set NODE_ENV to production in your shell or deployment environment before running node server.js.

For local development, create a root-level .env.local pointing to your running local MongoDB server:

MONGODB_URI=mongodb://127.0.0.1:27017/next_examples

Keep this file out of version control. In production, inject the URI through your deployment's secret configuration. Never name it NEXT_PUBLIC_MONGODB_URI: Next.js exposes variables with that prefix in browser bundles.

Reuse a Process-Local Mongoose Connection

Create db/connect.js. Cache the connection promise, not just its eventual result, so concurrent requests can await the same initialization attempt. Clear a rejected promise so a later request can retry.

// db/connect.js
import mongoose from 'mongoose';

const cache = globalThis.__stackchiefMongo ??= { promise: null };

export async function connectDB() {
  const uri = process.env.MONGODB_URI?.trim();
  if (!uri || !/^mongodb(?:\+srv)?:\/\//.test(uri)) {
    throw new Error('Set MONGODB_URI to a MongoDB connection URI');
  }

  if (!cache.promise) {
    cache.promise = mongoose.connect(uri, {
      maxPoolSize: 10,
      autoIndex: process.env.NODE_ENV !== 'production'
    }).catch((error) => {
      cache.promise = null;
      throw error;
    });
  }

  return cache.promise;
}

The initial check catches missing configuration and an incorrect URI scheme; Mongoose still validates and attempts the actual connection. The global cache also survives module reloads when the same JavaScript process remains alive, following the approach used in the official Next.js Mongoose example.

Reusing a Mongoose connection means reusing its underlying driver pools—not forcing all requests through one TCP socket. Each process or serverless instance has its own memory and client, and the driver maintains pools per MongoDB server. Here, maxPoolSize: 10 is an illustrative per-pool setting, not a deployment-wide connection limit.

Remove useNewUrlParser and useUnifiedTopology from older code. They have been unsupported connection options since Mongoose 6, as documented in its migration guide.

Define the Post Schema and Model

Create the model the original query depended on:

// db/model/Post.js
import mongoose from 'mongoose';

const postSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true, maxlength: 200 },
  published: { type: Boolean, default: false }
}, { timestamps: true });

postSchema.index({ published: 1, createdAt: -1, _id: -1 });

const Post = mongoose.models.Post
  || mongoose.model('Post', postSchema);

export default Post;

timestamps: true adds createdAt and updatedAt. Reusing mongoose.models.Post avoids recompiling the same model during hot reload. A model registered through mongoose.model() uses Mongoose's default connection—the one initialized by mongoose.connect().

The compound index matches this endpoint's publication filter and ordering. The connection helper disables automatic index builds in production, so create the index through a controlled migration before rollout. See our introduction to MongoDB indexing for the underlying trade-offs.

api/post.js: Query Posts Safely

This example intentionally exposes a public list of published post titles. It doesn't provide an authenticated administration API.

First, validate pagination independently of Express:

// lib/pagination.js
export class PaginationError extends Error {}

export function parsePage(value = '1') {
  if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
    throw new PaginationError('page must be an integer from 1 to 100');
  }
  const page = Number(value);
  if (!Number.isSafeInteger(page) || page > 100) {
    throw new PaginationError('page must be an integer from 1 to 100');
  }
  return page;
}

Now retain the original asynchronous query, descending createdAt sort, and JSON response, but bound the work:

// api/post.js
import express from 'express';
import Post from '../db/model/Post.js';
import { connectDB } from '../db/connect.js';
import { parsePage, PaginationError } from '../lib/pagination.js';

const router = express.Router();

router.get('/', async (req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  try {
    const page = parsePage(req.query.page);
    await connectDB();
    const q = Post.find({ published: true })
      .sort({ createdAt: -1, _id: -1 })
      .select('_id title createdAt')
      .skip((page - 1) * 20)
      .limit(20)
      .lean();
    const posts = await q.exec();
    return res.json(posts);
  } catch (error) {
    if (error instanceof PaginationError) {
      return res.status(400).json({ error: error.message });
    }
    console.error('GET /posts failed', {
      name: error?.name ?? 'UnknownError'
    });
    return res.status(500).json({ error: 'Database query failed' });
  }
});

export default router;

Requests such as /posts?page=2 return at most 20 documents. Missing page means page 1; invalid values and pages above 100 receive a 400 response. An empty database—or one without published posts—returns an empty array.

The projection selects only the fields intended for this response. lean() returns plain objects rather than hydrated Mongoose documents, which suits this read-only list; it also bypasses document features such as getters, virtuals, and save(). exec() executes the constructed query.

The _id tie-breaker makes ordering deterministic when timestamps match. Offset pagination is adequate for this deliberately capped example, but larger offsets require more scanning. For a growing feed, use cursor pagination based on the last createdAt/_id pair; neither a tie-breaker nor offset pagination creates a snapshot across concurrent writes. MongoDB's pagination documentation explains the scan cost and range-query alternative.

Start Express Only After MongoDB Is Ready

The custom server owns the HTTP listener. It mounts the posts router first, then passes remaining requests to Next.js:

Browser
   |
Express custom server
   |-- GET /posts --> Mongoose pool --> MongoDB
   |
   `-- other requests --> Next.js request handler
// server.js
import { createServer } from 'node:http';
import { once } from 'node:events';
import express from 'express';
import next from 'next';
import nextEnv from '@next/env';
import posts from './api/post.js';
import { connectDB } from './db/connect.js';

async function main() {
  const dev = process.env.NODE_ENV !== 'production';
  nextEnv.loadEnvConfig(process.cwd(), dev);

  const port = Number(process.env.PORT ?? 3000);
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    throw new Error('PORT must be an integer from 1 to 65535');
  }

  const nextApp = next({ dev });
  await connectDB();
  await nextApp.prepare();
  const handle = nextApp.getRequestHandler();
  const app = express();

  app.use('/posts', posts);
  app.use(async (req, res) => handle(req, res));
  app.use((error, req, res, nextMiddleware) => {
    console.error('HTTP request failed', {
      name: error?.name ?? 'UnknownError'
    });
    if (res.headersSent) return nextMiddleware(error);
    return res.status(500).json({ error: 'Request failed' });
  });

  const server = createServer(app);
  server.listen(port);
  await once(server, 'listening');
  console.log(`Listening on port ${port}`);
}

main().catch((error) => {
  console.error('Server startup failed', {
    name: error?.name ?? 'UnknownError'
  });
  process.exit(1);
});

NODE_ENV, not NODE_DEV, selects development mode. The listener opens only after the connection and Next.js preparation succeed. @next/env loads the environment files for this entry point outside the normal Next.js CLI startup path; see the environment-variable guide.

The pathless app.use() fallback avoids the old app.get('*', ...) pattern. Express 5 requires named string wildcards, and rejected promises from asynchronous middleware are forwarded to error handling. Its migration guide documents both changes.

This GET-only endpoint needs no request-body parser. Add parsing and body validation to write routes when you introduce them.

Run npm run dev locally. For production, run npm run build before npm start. This arrangement needs hosting that runs your custom Node.js server; it isn't a drop-in serverless-function deployment, and Next.js standalone output cannot be combined with a custom server.

Use a Next.js MongoDB API Route Without Express

If Express exists only to expose this endpoint, a Pages Router API route can replace it. Keep the connection helper, model, and pagination validator, and create pages/api/posts.js:

// pages/api/posts.js
import Post from '../../db/model/Post.js';
import { connectDB } from '../../db/connect.js';
import { parsePage, PaginationError } from '../../lib/pagination.js';

export default async function handler(req, res) {
  res.setHeader('Cache-Control', 'no-store');
  if (req.method !== 'GET') {
    res.setHeader('Allow', 'GET');
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const page = parsePage(req.query.page);
    await connectDB();
    const posts = await Post.find({ published: true })
      .sort({ createdAt: -1, _id: -1 })
      .select('_id title createdAt')
      .skip((page - 1) * 20)
      .limit(20)
      .lean()
      .exec();
    return res.status(200).json(posts);
  } catch (error) {
    if (error instanceof PaginationError) {
      return res.status(400).json({ error: error.message });
    }
    console.error('GET /api/posts failed', {
      name: error?.name ?? 'UnknownError'
    });
    return res.status(500).json({ error: 'Database query failed' });
  }
}

This file serves /api/posts, not /posts. Remove the custom server if nothing else needs it, and restore next dev and next start as the development and production scripts.

For an App Router application, use app/api/posts/route.js with an exported GET function and return a Web Response, such as Response.json(posts). Reuse the same database helper and query protections. Keep Mongoose on the Node.js runtime: it cannot connect to MongoDB from the Next.js Edge runtime.

Production Checklist

  • Enforce the intended audience: the example is public and read-only. For private data, authenticate before querying and include authorization constraints, such as tenant ownership, in the database filter.
  • Keep filters under server control: don't pass a request's query object or body directly into Post.find(). Validate supported inputs and explicitly select response fields.
  • Protect the database: use appropriately restricted credentials, TLS, and network access controls—not the unauthenticated local-development setup.
  • Make errors useful without leaking secrets: the sample logs an operation label and error name. Add request IDs, latency, and sanitized diagnostics to protected server logs; don't return URIs, credentials, or stack traces to clients.

These boundaries follow the OWASP NoSQL security guidance. Model validation alone doesn't establish them.

  • Budget connections across instances: tune pool size against concurrency and database capacity, accounting for topology and monitoring connections. A warm-instance cache doesn't prevent cold instances from creating their own pools.
  • Own the lifecycle: monitor connection errors and disconnections, test database outages, and implement shutdown that stops new HTTP requests, drains active work with a deadline, and then disconnects Mongoose. Don't close a shared pool while requests still depend on it.
  • Test the contract: cover missing configuration, invalid pages, empty results, published-only visibility, response-field restrictions, and query failures.

Conclusion

The useful part of the original design still holds: initialize MongoDB access once per process and reuse it for server-side queries. Keep Express when your application needs its custom-server behavior; otherwise, move the endpoint into Next.js without changing the database boundary.

FAQ

How do I connect MongoDB to a Next.js application with Mongoose?

Create a reusable server-side connection helper, register your models once, and await the helper before querying. In a custom Express server, also await database readiness before listening for requests.

Should I use Mongoose or the MongoDB driver in Next.js?

Use Mongoose when schema-based models, validation, and middleware suit your application. Choose the native MongoDB driver when you prefer direct driver APIs without Mongoose's model layer; either approach needs connection management and access controls.

How do I reuse a Mongoose connection in Next.js serverless functions?

Cache the connection promise in module or global scope and reuse it while the instance remains warm. Each process or serverless instance has its own cache and pools, so this does not create one deployment-wide connection.

Do I need a custom Express server to use MongoDB with Next.js?

No. Pages Router API routes and App Router route handlers can query MongoDB on the server; retain Express only when your existing application actually needs custom server behavior.

How do I query MongoDB from a Next.js App Router route handler?

Create app/api/posts/route.js, export an async GET handler, await the shared connection helper, and return query results with Response.json(). Use the Node.js runtime for Mongoose, and apply the same validation, authorization, projection, and pagination as in the Express endpoint.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me