DEV Community

Anas Sheikh
Anas Sheikh

Posted on

MongoDB + Mongoose Patterns I Use in Every Next.js Project

Every MERN project starts the same way.

You copy-paste a connectDB function from an old repo. You write a schema with no validation. Three months later you're debugging duplicate connections in production.

Here's the exact Mongoose setup I use in every Next.js project now — no more guessing.


1. The Connection Caching Pattern

Next.js reuses modules across hot reloads and serverless invocations. Without caching, you'll open a new MongoDB connection on every request.

// lib/db.ts
import mongoose from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI as string;

if (!MONGODB_URI) {
  throw new Error('Missing MONGODB_URI in .env');
}

interface MongooseCache {
  conn: typeof mongoose | null;
  promise: Promise<typeof mongoose> | null;
}

declare global {
  var mongooseCache: MongooseCache | undefined;
}

const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null };

export async function connectDB() {
  if (cached.conn) return cached.conn;

  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI, {
      bufferCommands: false,
    });
  }

  cached.conn = await cached.promise;
  global.mongooseCache = cached;
  return cached.conn;
}
Enter fullscreen mode Exit fullscreen mode

The global cache survives hot reloads in dev and warm serverless functions in production. This one file fixes 90% of the "too many connections" errors people hit on Vercel.


2. Schema Design That Doesn't Bite You Later

// models/User.ts
import mongoose, { Schema, models, model } from 'mongoose';

export interface IUser {
  name: string;
  email: string;
  role: 'admin' | 'user';
  avatar?: string;
  createdAt: Date;
  updatedAt: Date;
}

const UserSchema = new Schema<IUser>(
  {
    name: {
      type: String,
      required: [true, 'Name is required'],
      trim: true,
      maxlength: 50,
    },
    email: {
      type: String,
      required: [true, 'Email is required'],
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, 'Invalid email format'],
    },
    role: {
      type: String,
      enum: ['admin', 'user'],
      default: 'user',
    },
    avatar: String,
  },
  { timestamps: true }
);

// Prevents model overwrite errors on hot reload
export const User = models.User || model<IUser>('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

Three things that always bite people: no unique index on email, no timestamps: true, and redefining the model on every hot reload. The models.User || check solves that last one for good.


3. Typing Documents vs. Plain Objects

Mongoose documents and plain JS objects are not the same shape, and mixing them up causes constant type errors:

// types/db.ts
import { IUser } from '@/models/User';

// What you get back from Mongoose (has _id, methods, etc.)
export type UserDocument = IUser & { _id: string };

// What you send to the client (safe to serialize)
export type UserDTO = Omit<UserDocument, 'password'>;

export function toUserDTO(doc: any): UserDTO {
  return {
    _id: doc._id.toString(),
    name: doc.name,
    email: doc.email,
    role: doc.role,
    avatar: doc.avatar,
    createdAt: doc.createdAt,
    updatedAt: doc.updatedAt,
  };
}
Enter fullscreen mode Exit fullscreen mode

Never pass a raw Mongoose document straight into a Server Component or Response.json(). Convert it first — otherwise you'll hit "Only plain objects can be passed to Client Components" errors.


4. Query Helpers Instead of Repeating .lean() Everywhere

// lib/queries/users.ts
import { connectDB } from '@/lib/db';
import { User } from '@/models/User';
import { toUserDTO, UserDTO } from '@/types/db';

export async function getUserById(id: string): Promise<UserDTO | null> {
  await connectDB();
  const user = await User.findById(id).lean();
  return user ? toUserDTO(user) : null;
}

export async function getUsersByRole(role: 'admin' | 'user'): Promise<UserDTO[]> {
  await connectDB();
  const users = await User.find({ role }).sort({ createdAt: -1 }).lean();
  return users.map(toUserDTO);
}
Enter fullscreen mode Exit fullscreen mode

.lean() returns plain JS objects instead of full Mongoose documents — faster, and avoids serialization issues in Server Components. Wrapping every query in a typed function also means you never write raw User.find() inside a page component again.


5. Validation Before It Touches the Database

Mongoose validation runs at the database layer, but you want to reject bad input before that — same as the Zod pattern from typed Server Actions:

// lib/validations/user.ts
import { z } from 'zod';

export const CreateUserSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
});

export type CreateUserInput = z.infer<typeof CreateUserSchema>;
Enter fullscreen mode Exit fullscreen mode
// actions/users.ts
'use server';
import { connectDB } from '@/lib/db';
import { User } from '@/models/User';
import { CreateUserSchema } from '@/lib/validations/user';
import { revalidatePath } from 'next/cache';

export async function createUser(formData: FormData) {
  const parsed = CreateUserSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });

  if (!parsed.success) {
    return { success: false, message: 'Validation failed' };
  }

  await connectDB();

  const exists = await User.findOne({ email: parsed.data.email });
  if (exists) {
    return { success: false, message: 'Email already in use' };
  }

  await User.create(parsed.data);
  revalidatePath('/dashboard/users');
  return { success: true, message: 'User created' };
}
Enter fullscreen mode Exit fullscreen mode

Zod catches malformed input. The findOne check catches duplicates before Mongoose throws a raw E11000 error you'd otherwise have to parse.


6. Indexes You Actually Need

Most people never add indexes beyond the default _id. On any collection you filter or sort by often, add one:

UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ role: 1, createdAt: -1 });
Enter fullscreen mode Exit fullscreen mode

The second index speeds up exactly the kind of query from getUsersByRole above — filter by role, sort by createdAt. Without it, Mongo scans the full collection once it grows past a few thousand documents.


Summary

Pattern Why
Cached connection in lib/db.ts Stops duplicate connections on hot reload / serverless
`models.User \ \
Separate Document / DTO types Keeps Server Components from choking on Mongoose objects
{% raw %}.lean() in query helpers Faster reads, plain objects, one place to change queries
Zod validation before User.create Clean errors instead of raw Mongoose exceptions
Indexes on filtered/sorted fields Keeps queries fast as collections grow

This is the same lib/db.ts and model setup across every MERN project I ship — dashboards, SaaS backends, and the templates I sell.

See it in a real codebase:

Get the templates: https://pixelanas.gumroad.com

How do you handle Mongoose connections in serverless? Drop it below 👇


Anas — full-stack Next.js developer building SaaS products and premium templates. X: @pixelanas

Top comments (0)