MongoDB in 2026: Still the Right Choice — When You Use It Right
MongoDB 8.0 delivers up to 30% faster reads on large collections, improved sharding, and Atlas Vector Search as a first-class feature — making it a natural fit for AI-augmented applications. Mongoose 8.x dramatically improves TypeScript support with InferSchemaType and better type inference for queries.
But MongoDB's flexibility is also its biggest footgun. Without discipline in schema design and indexing, MongoDB applications degrade fast at scale. This guide covers the patterns that work in production and the mistakes that will cost you at 3 AM.
Schema Design: The Golden Rules
Embed vs Reference — The Decision Framework
This is the single most important decision in MongoDB schema design. Get it wrong and you'll be fighting your data model forever.
| Embed When... | Reference When... |
|---|---|
| Data is always accessed together | Data is queried independently |
| Sub-document is bounded in size (≤100 items) | Array could grow unbounded |
| Updates are infrequent | Updates are frequent and independent |
| One-to-few relationship | One-to-many or many-to-many |
// ✅ EMBED — addresses belong to a user, always accessed together, bounded
const UserSchema = new Schema({
name: String,
email: String,
addresses: [{
label: String, // "Home", "Work"
street: String,
city: String,
zipCode: String,
}], // max 5-10 addresses per user — bounded
});
// ✅ REFERENCE — posts are independent entities, queried separately, unbounded
const UserSchema = new Schema({
name: String,
email: String,
// NOT: posts: [{ type: ObjectId, ref: "Post" }] ❌ — could grow to millions
});
const PostSchema = new Schema({
title: String,
content: String,
author: { type: ObjectId, ref: "User" }, // reference back to user
});
// ✅ HYBRID — embed summary, reference full data
const BlogSchema = new Schema({
title: String,
content: String,
author: {
_id: { type: ObjectId, ref: "User" },
name: String, // denormalized for display
avatar: String, // denormalized for display
},
// Avoids populate() for the 90% case (displaying author name + avatar)
// Can still populate for the 10% case (full user profile link)
});
Mongoose 8.x TypeScript Integration
Mongoose 8 introduced InferSchemaType which eliminates the need for manual interface definitions:
import { Schema, model, InferSchemaType } from "mongoose";
const blogSchema = new Schema({
title: { type: String, required: true, trim: true },
slug: { type: String, required: true, unique: true, lowercase: true },
excerpt: { type: String, required: true },
content: { type: String, required: true },
featuredImage: { type: String, required: true },
category: { type: String, required: true },
tags: { type: [String], default: [] },
author: { type: Schema.Types.ObjectId, ref: "User", required: true },
readingTime: { type: Number, default: 1 },
status: { type: String, enum: ["draft", "published", "scheduled"], default: "draft" },
publishedAt: Date,
seoTitle: { type: String, trim: true },
seoDescription: { type: String, trim: true },
}, { timestamps: true });
// Type is automatically inferred from the schema — no manual interface!
type Blog = InferSchemaType<typeof blogSchema>;
// Blog type includes:
// { title: string; slug: string; status: "draft" | "published" | "scheduled"; ... }
export const Blog = model("Blog", blogSchema);
Indexing Strategy
Indexes are the difference between a 2ms query and a 2-second query. But wrong indexes waste memory and slow writes. Here's the strategy:
Compound Indexes — Match Your Query Patterns
// Your common query: "find published blogs, sorted by date"
// Index MUST match the query shape: filter fields first, sort field last
blogSchema.index({ status: 1, publishedAt: -1 });
// Your common query: "find blogs by category, sorted by date"
blogSchema.index({ category: 1, publishedAt: -1 });
// Your common query: "find user's blogs by status"
blogSchema.index({ author: 1, status: 1, createdAt: -1 });
Text Search Index
// Full-text search across title and content
blogSchema.index(
{ title: "text", content: "text", tags: "text" },
{ weights: { title: 10, tags: 5, content: 1 } } // title matches rank higher
);
// Usage
const results = await Blog.find(
{ $text: { $search: "nextjs react" } },
{ score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } });
TTL Index — Auto-Delete Expired Documents
// Verification tokens expire after 24 hours — auto-deleted
tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// Sessions expire after 30 days
sessionSchema.index({ lastActive: 1 }, { expireAfterSeconds: 2592000 });
Explain Your Queries — Always
const explain = await Blog
.find({ status: "published" })
.sort({ publishedAt: -1 })
.limit(10)
.explain("executionStats");
// Check these values:
console.log({
docsExamined: explain.executionStats.totalDocsExamined,
docsReturned: explain.executionStats.nReturned,
executionTimeMs: explain.executionStats.executionTimeMillis,
indexUsed: explain.queryPlanner.winningPlan.inputStage?.indexName,
});
// RULE: totalDocsExamined should be close to nReturned
// If examining 100,000 docs to return 10, you need an index
Aggregation Pipelines
For anything beyond simple CRUD, aggregation pipelines are MongoDB's most powerful feature:
// Dashboard stats — single pipeline instead of 4 separate queries
const stats = await Blog.aggregate([
{
$facet: {
byStatus: [
{ $group: { _id: "$status", count: { $sum: 1 } } }
],
byCategory: [
{ $match: { status: "published" } },
{ $group: { _id: "$category", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
],
recentlyPublished: [
{ $match: { status: "published" } },
{ $sort: { publishedAt: -1 } },
{ $limit: 5 },
{ $project: { title: 1, slug: 1, publishedAt: 1 } }
],
totalReadingTime: [
{ $match: { status: "published" } },
{ $group: { _id: null, total: { $sum: "$readingTime" } } }
]
}
}
]);
Connection Management in Serverless
// lib/db/connect.ts — production singleton pattern
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI!;
interface Cached {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
// Survive between warm Lambda/serverless invocations
const cached: Cached = (global as any).mongoose ?? { conn: null, promise: null };
if (!(global as any).mongoose) (global as any).mongoose = cached;
export default async function dbConnect() {
if (cached.conn) return cached.conn;
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
bufferCommands: false, // fail fast if not connected
});
}
try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null; // reset on failure
throw e;
}
return cached.conn;
}
Atlas Vector Search for AI Features
MongoDB Atlas now supports native vector search — no separate vector database needed:
// Schema with embedding field
const articleSchema = new Schema({
title: String,
content: String,
embedding: {
type: [Number],
validate: (v: number[]) => v.length === 1536, // text-embedding-3-small
},
});
// Create Atlas Vector Search index (via Atlas UI or API)
// Index name: "vector_index", field: "embedding", similarity: "cosine"
// Semantic search
async function semanticSearch(query: string, limit = 5) {
const queryEmbedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: query,
});
return Article.aggregate([
{
$vectorSearch: {
index: "vector_index",
queryVector: queryEmbedding.data[0].embedding,
path: "embedding",
numCandidates: 100,
limit,
}
},
{
$project: {
title: 1,
content: 1,
score: { $meta: "vectorSearchScore" },
}
}
]);
}
Common Mistakes That Cost Performance
- Unbounded arrays: Embedding a growing list (comments, logs, events) inside a document. The 16MB document limit will eventually hit you, and updating large arrays is slow.
- Missing indexes: Every query that touches production should be explained. No exceptions.
- Populate chains:
Blog.find().populate("author").populate("comments.user")= N+1 queries. Use aggregation$lookupfor complex joins, or denormalize frequently accessed fields. - Not using projections:
Blog.find()returns ALL fields. If you only need title and slug, useBlog.find({}, { title: 1, slug: 1 }). - Lean queries:
Blog.find().lean()returns plain objects instead of Mongoose documents — 2-3x faster for read-only operations.
We design data layers that perform at scale — from schema design to indexing to vector search. Let's discuss your project →