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;
}
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);
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,
};
}
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);
}
.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>;
// 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' };
}
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 });
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)