Skip to content

Full-stack

MongoDB from Next.js route handlers

Connecting a Mongoose model to an App Router route handler without opening a new pool per request, and being honest about what the single-service boundary costs you.

3 min readUpdated 2 Oct 20263 sections

The Unity Shop frontend talks to MongoDB directly, with Mongoose as a dependency of the Next.js application rather than of a separate service. That is a legitimate architecture for a small team, and the reason it can work at all is connection reuse.

Get that wrong and every request opens its own set of sockets, which is the single most common way a serverless deployment runs out of file descriptors or hits the connection limit.

Reuse the connection

The driver manages a pool underneath, and mongoose.connect will reuse an existing connection if one is already open. The pattern is to keep the promise in a module-level variable that survives between requests in the same instance, and await that promise on each call.

lib/mongoose.ts
import mongoose from "mongoose";

// Survives across requests handled by the same warm instance.
let connection: Promise<typeof mongoose> | null = null;

const uri = process.env.MONGODB_URI;
if (!uri) throw new Error("MONGODB_URI is not set");

export function connect() {
  connection ??= mongoose
    .connect(uri, {
      // Fail fast instead of buffering commands until the timeout.
      bufferCommands: false,
      serverSelectionTimeoutMS: 5000,
    })
    .catch((error) => {
      // Clear the cached promise so the next request can retry.
      connection = null;
      throw error;
    });

  return connection;
}
Condensed from the connection pattern this project relies on.

bufferCommands: false is the other decision worth making. The default is to queue operations while the driver is connecting, which turns a slow connection into a slow request instead of a clear error. Turning it off means a cold start fails fast and the platform can retry the invocation, which is the behaviour you actually want.

A handler that leans on it

app/api/products/route.ts
import { NextResponse } from "next/server";
import { connect } from "@/lib/mongoose";
import { Product } from "@/models/Product";

export async function GET(req: Request) {
  await connect();

  const { searchParams } = new URL(req.url);
  const page = Math.max(1, Number(searchParams.get("page") ?? 1));
  const limit = Math.min(48, Number(searchParams.get("limit") ?? 24));

  const [items, total] = await Promise.all([
    Product.find({ status: "active" })
      .sort({ createdAt: -1 })
      .skip((page - 1) * limit)
      .limit(limit)
      .lean(),
    Product.countDocuments({ status: "active" }),
  ]);

  return NextResponse.json({ items, total, page, limit });
}

Three details there are ordinary and easy to get wrong. limit is clamped so a client cannot ask for the entire collection. The two queries run concurrently with Promise.all rather than in sequence, because on a cold connection the round trip dominates. And .lean() skips Mongoose's document hydration, which matters when you are serialising the whole result to JSON anyway.

What the single-service boundary costs

Running the data access inside the frontend deploy is convenient, and the costs are worth writing down rather than discovering.

  • The database's availability is now the app's availability. There is no second service that can absorb a traffic spike, so everything scales together or not at all.
  • Schema changes ship with the UI. A migration that breaks the API breaks the pages, and there is no window where the two can be deployed independently.
  • Cold starts are on the critical path. The first request after a period of inactivity pays for the database handshake. Keeping the connection warm is the mitigation.
  • There is no network boundary to enforce anything. Anything that can reach a route handler can reach the models, so authorisation has to be explicit in each handler rather than implied by deployment topology.