MongoDB Integration Guide
███╗ ███╗ ██████╗ ███╗ ██╗ ██████╗ ██████╗ ██████╗ ██████╗
████╗ ████║██╔═══██╗████╗ ██║██╔════╝ ██╔═══██╗██╔══██╗██╔══██╗
██╔████╔██║██║ ██║██╔██╗ ██║██║ ███╗██║ ██║██║ ██║██████╔╝
██║╚██╔╝██║██║ ██║██║╚██╗██║██║ ██║██║ ██║██║ ██║██╔══██╗
██║ ╚═╝ ██║╚██████╔╝██║ ╚████║╚██████╔╝╚██████╔╝██████╔╝██████╔╝
╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝MongoDB Integration Guide
Focus: When and how codeAmani uses MongoDB for client projects that require a document database — Atlas setup and connection strings, the serverless connection-caching pattern (critical on Vercel/Netlify), Mongoose schemas validated with Zod at the edge, the aggregation pipeline, transactions, and Atlas Vector Search as a pgvector/Pinecone alternative for RAG.
Overview
MongoDB is a document database advertised as an option on motionstackstudios.com, and it stores BSON documents in collections rather than rows in tables. That flexibility — nested objects, arrays, per-document variable shape — is its whole point, and also the reason it is not the house default.
The house default for almost everything is Neon Postgres + Drizzle ORM (with Supabase as the second relational option). Postgres with jsonb columns covers a surprising amount of "document-ish" need while keeping the strong typing, joins, transactions, and per-PR branching the rest of the stack already relies on. MongoDB earns a place only when a client build specifically needs a document model — a flexible/evolving schema, deeply nested data that would be painful to normalize, rapid prototyping where the shape is still moving, or a per-tenant document whose fields differ per customer. The decision flow:
Check first. Before adding MongoDB to a build, query the
codeAmani-tech-stackMCP (search_guides/get_guide) and confirm the house Neon/Postgres default — includingjsonb— won't satisfy the requirement. MongoDB adds a second database paradigm, its own ODM, and serverless connection-pool footguns. Prefer the default unless the client specifically requires a document store.
Official Documentation
| Resource | URL |
|---|---|
| MongoDB Docs (root) | https://www.mongodb.com/docs/ |
| MongoDB Atlas (managed) | https://www.mongodb.com/docs/atlas/ |
| Node.js Driver (v6, current) | https://www.mongodb.com/docs/drivers/node/current/ |
| Mongoose ODM | https://mongoosejs.com/docs/ |
| Atlas Vector Search | https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-overview/ |
| Aggregation Pipeline | https://www.mongodb.com/docs/manual/core/aggregation-pipeline/ |
MCP note: an official MongoDB MCP server exists and ships with the house plugin stack — it can
find,aggregate, inspectcollection-schema, managecreate-index, and drive Atlas local deployments from Claude Code. Use it for read/inspection and scaffolding, but treat production writes through it with the same care as a CLI against prod.
MongoDB Atlas Setup & Connection String
MongoDB Atlas is the managed, multi-cloud MongoDB service and is the only deployment shape codeAmani uses for clients — never self-host a mongod for a client build. Setup:
- Create a project and a cluster (an M0 free tier or Flex cluster is fine for prototypes; dedicated M10+ for production with PHI/regulated data).
- Create a database user (username + password) under Database Access.
- Add the deploying environment's egress to the Network Access IP allowlist. Serverless platforms have dynamic egress IPs — for Vercel/Netlify Lambda you typically allow
0.0.0.0/0and rely on TLS + SCRAM auth + a strong user password, or front it with a static-egress proxy. Lock down to known CIDRs whenever the platform supports it. - Copy the SRV connection string from Connect → Drivers.
The SRV string carries the cluster host, credentials, and sensible defaults. Always keep retryWrites=true&w=majority:
mongodb+srv://<user>:<urlEncodedPassword>@cluster0.xxxxx.mongodb.net/<dbName>?retryWrites=true&w=majority&appName=acme-appGotcha: the password must be URL-encoded in the connection string. A literal
@,:,/, or#in the password will break parsing — encodeP@ss:w0rdasP%40ss%3Aw0rd. Store the assembled URI inMONGODB_URI(server-side secret) and never inline credentials in code.
Serverless Connection Caching (critical on Vercel/Netlify)
This is the single most important pattern in the guide. On serverless platforms each invocation may spin up a fresh Lambda; if you call new MongoClient(...).connect() per request you open a new pool every time and exhaust the Atlas connection limit under load. The fix is to cache the connection on the Node module/global scope so warm invocations reuse it.
Raw driver (mongodb v6) — cached client singleton
// lib/mongodb.ts
import { MongoClient, type Db } from "mongodb";
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error("Missing MONGODB_URI environment variable");
// In serverless, cache the connect() promise on globalThis so it survives
// module re-evaluation across warm invocations (and HMR in dev).
const options = { maxPoolSize: 10, minPoolSize: 0 };
declare global {
// eslint-disable-next-line no-var
var _mongoClientPromise: Promise<MongoClient> | undefined;
}
let clientPromise: Promise<MongoClient>;
if (process.env.NODE_ENV === "development") {
// Reuse across hot-reloads in dev.
if (!global._mongoClientPromise) {
global._mongoClientPromise = new MongoClient(uri, options).connect();
}
clientPromise = global._mongoClientPromise;
} else {
// In production, the module-scoped promise is reused by warm invocations.
clientPromise = new MongoClient(uri, options).connect();
}
export async function getDb(dbName = "app"): Promise<Db> {
const client = await clientPromise;
return client.db(dbName);
}
export default clientPromise;Why a cached promise, not a cached client? Caching the
connect()promise means concurrent cold-start requestsawaitthe same in-flight connection instead of each opening a pool. Never callclient.close()in a request handler on serverless — let the platform recycle the container.
Mongoose — cached connection
Mongoose needs the same treatment. Cache both the connection and its in-flight promise:
// lib/dbConnect.ts
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) throw new Error("Missing MONGODB_URI environment variable");
interface MongooseCache {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
const globalForMongoose = global as unknown as { mongoose?: MongooseCache };
const cached: MongooseCache = globalForMongoose.mongoose ?? { conn: null, promise: null };
globalForMongoose.mongoose = cached;
export default async function dbConnect(): Promise<typeof mongoose> {
if (cached.conn) return cached.conn;
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI, {
bufferCommands: false, // fail fast instead of queueing before connect
maxPoolSize: 10,
});
}
cached.conn = await cached.promise;
return cached.conn;
}Note: these are Node.js runtime patterns. On Next.js, mark routes that touch MongoDB with
export const runtime = "nodejs"— the driver and Mongoose do not run on the Edge runtime. For Edge/Workers, use the Atlas Data API or a Postgres/Neon path instead.
Mongoose Schema + Zod at the Edge
The house pattern mirrors the relational stack: validate input at the boundary with Zod, then persist with a typed Mongoose model. Zod guards the untrusted edge (request bodies, webhooks); the Mongoose schema is the persistence contract and last-line validation. Define an explicit TypeScript interface so types don't drift.
// models/Provider.ts
import { Schema, model, models, type InferSchemaType, type Model } from "mongoose";
import { z } from "zod";
// 1. Zod schema validates the untrusted edge (API body, webhook payload).
export const ProviderInput = z.object({
name: z.string().min(1),
licenseType: z.enum(["APD", "AHCA", "DCF"]),
email: z.string().email(),
services: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).optional(), // flexible per-client shape
});
export type ProviderInput = z.infer<typeof ProviderInput>;
// 2. Mongoose schema is the persistence contract + last-line validation.
const providerSchema = new Schema(
{
name: { type: String, required: true, trim: true },
licenseType: { type: String, required: true, enum: ["APD", "AHCA", "DCF"] },
email: { type: String, required: true, lowercase: true, index: true },
services: { type: [String], default: [] },
metadata: { type: Schema.Types.Mixed }, // free-form nested document
},
{ timestamps: true, strict: true },
);
export type Provider = InferSchemaType<typeof providerSchema>;
// 3. `models.Provider ?? model(...)` prevents OverwriteModelError on hot-reload
// and across warm serverless invocations.
export const ProviderModel: Model<Provider> =
(models.Provider as Model<Provider>) ?? model<Provider>("Provider", providerSchema);Usage in a route handler — Zod first, then Mongoose:
// app/api/providers/route.ts
import { NextResponse } from "next/server";
import dbConnect from "@/lib/dbConnect";
import { ProviderInput, ProviderModel } from "@/models/Provider";
export const runtime = "nodejs";
export async function POST(req: Request) {
const parsed = ProviderInput.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
await dbConnect();
const provider = await ProviderModel.create(parsed.data);
return NextResponse.json({ id: provider._id.toString() }, { status: 201 });
}Schema validation belongs on both sides. Use Atlas/MongoDB JSON Schema validation at the collection level (
$jsonSchemavalidators) as a server-enforced backstop for writes that bypass the app (scripts, the MCP, direct driver access). The Mongoose schema does not protect the database from other clients — collection validators do.
Aggregation Pipeline
The aggregation pipeline is MongoDB's query engine for analytics, joins ($lookup), grouping, and reshaping. Stages run in order; the output of one feeds the next. A typical "providers and their open intake counts" rollup:
// lib/reports.ts
import { getDb } from "@/lib/mongodb";
export async function providerIntakeSummary(licenseType: "APD" | "AHCA" | "DCF") {
const db = await getDb();
const cursor = db.collection("providers").aggregate([
{ $match: { licenseType } },
{
$lookup: {
from: "intakes",
localField: "_id",
foreignField: "providerId",
as: "intakes",
},
},
{
$project: {
_id: 0,
name: 1,
email: 1,
openIntakes: {
$size: {
$filter: {
input: "$intakes",
as: "i",
cond: { $eq: ["$$i.status", "open"] },
},
},
},
},
},
{ $sort: { openIntakes: -1 } },
{ $limit: 50 },
]);
return cursor.toArray();
}Index the
$matchand$sortfields. Aggregations are only fast when the early$match/$sortstages hit an index; otherwise Atlas does a full collection scan. Use the MCPexplaintool (ordb.collection.explain()) to confirm anIXSCAN, not aCOLLSCAN.
Transactions
MongoDB supports multi-document ACID transactions on replica sets (every Atlas cluster is a replica set). Use them when you must write to multiple documents/collections atomically — e.g. debiting one balance and crediting another. The driver's withTransaction helper handles commit/abort and transient-error retries:
// lib/transfer.ts
import { getDb } from "@/lib/mongodb";
import clientPromise from "@/lib/mongodb";
export async function recordIntake(providerId: string, intake: Record<string, unknown>) {
const client = await clientPromise;
const db = await getDb();
const session = client.startSession();
try {
await session.withTransaction(async () => {
await db.collection("intakes").insertOne({ providerId, ...intake, status: "open" }, { session });
await db
.collection("providers")
.updateOne({ _id: providerId }, { $inc: { openCount: 1 } }, { session });
});
} finally {
await session.endSession();
}
}Gotcha: transactions have a default 60-second limit and are not free — they hold locks and add latency. Reach for them only when atomicity across documents is genuinely required. Often, embedding related data in a single document (MongoDB's strength) removes the need for a transaction entirely.
Atlas Vector Search (RAG — pgvector/Pinecone alternative)
Atlas Vector Search stores embeddings alongside your documents and runs approximate-nearest-neighbor search via the $vectorSearch aggregation stage. For RAG, it is a real alternative to the house options: pgvector on Neon (when you're already on Postgres) and Pinecone (dedicated vector DB). Choose Atlas Vector Search when the client is already on MongoDB and you want embeddings to live next to the source documents — no second datastore to sync.
First, create a vector search index on the embedding field (via Atlas UI, the MCP, or the driver). A 1536-dim index for OpenAI text-embedding-3-small, cosine similarity:
{
"fields": [
{
"type": "vector",
"path": "embedding",
"numDimensions": 1536,
"similarity": "cosine"
},
{ "type": "filter", "path": "tenantId" }
]
}Then query with $vectorSearch as the first pipeline stage:
// lib/vectorSearch.ts
import { getDb } from "@/lib/mongodb";
export async function searchDocs(queryEmbedding: number[], tenantId: string, k = 5) {
const db = await getDb();
return db
.collection("knowledge")
.aggregate([
{
$vectorSearch: {
index: "knowledge_vector_index",
path: "embedding",
queryVector: queryEmbedding,
numCandidates: 150, // ~10-20x limit for good recall
limit: k,
filter: { tenantId }, // pre-filter so tenants never see each other's docs
},
},
{
$project: {
_id: 0,
text: 1,
source: 1,
score: { $meta: "vectorSearchScore" },
},
},
])
.toArray();
}| RAG store | When to choose |
|---|---|
| Neon + pgvector (house default for Postgres builds) | Already on Postgres; want vectors in the same DB as relational data; SQL filtering |
| Pinecone (house dedicated option) | Large-scale, vector-first workloads; managed ANN at scale; multi-tenant namespaces |
| Atlas Vector Search | Already on MongoDB; want embeddings beside the source documents; one datastore |
Multi-tenant safety: always pass a
filter(e.g.tenantId) in$vectorSearchand index it as afilterfield — this is the same isolation discipline as aWHERE tenant_id = ...clause. Never return another tenant's vectors.
Environment Variables
# Atlas SRV connection string — server-side secret. NEVER ship to the browser bundle.
# Password MUST be URL-encoded. Keep retryWrites=true&w=majority.
MONGODB_URI=mongodb+srv://app_user:URL%2DENCODED%2DPASS@cluster0.xxxxx.mongodb.net/app?retryWrites=true&w=majority&appName=acme-app
# Optional: explicit database name if not in the URI path
MONGODB_DB=appAdd these to
ENV_MASTER.mdand each project's.env.example.MONGODB_URIis a server-only secret — it contains credentials; never expose it viaNEXT_PUBLIC_*or any client bundle. In production, source it from the secrets manager (Infisical / Vercel env vars), not a committed file.
Common Use Cases
| Use Case | Approach |
|---|---|
| Default relational database | Neon Postgres + Drizzle (house default) — use MongoDB only for true document needs |
| Semi-structured / occasional nesting | Postgres jsonb on Neon before reaching for MongoDB |
| Flexible / evolving / per-tenant schema | MongoDB Atlas + Mongoose Schema.Types.Mixed |
| Serverless connection (Vercel/Netlify) | Cached client/connection promise on globalThis (never connect per request) |
| Input validation | Zod at the edge + Mongoose schema + collection $jsonSchema validator |
| Analytics / joins / rollups | Aggregation pipeline ($match → $lookup → $group → $project) |
| Atomic multi-document writes | session.withTransaction(...) (only when embedding can't avoid it) |
| RAG / semantic search on Mongo data | Atlas Vector Search $vectorSearch (else Neon+pgvector or Pinecone) |
| Inspect schema / indexes / run queries | MongoDB MCP (collection-schema, collection-indexes, find, aggregate, explain) |
Troubleshooting
| Issue | Fix |
|---|---|
| Connection-pool exhaustion on serverless | Cache the connect() promise on globalThis; never new MongoClient() per request, never close() in a handler |
MongoParseError / auth fails on connect | URL-encode the password in MONGODB_URI (@→%40, :→%3A); verify the DB user under Database Access |
MongoServerSelectionError / timeout | Add the platform's egress to the Atlas Network Access allowlist; on dynamic-IP serverless allow 0.0.0.0/0 + rely on TLS/SCRAM |
OverwriteModelError: Cannot overwrite model | Guard model creation with models.X ?? model("X", schema) so HMR/warm invocations reuse it |
MongooseError: Operation buffering timed out | Set bufferCommands: false and await dbConnect() before any query; the connection wasn't established |
| Runs on Edge / Workers and fails | Driver & Mongoose are Node-only — set export const runtime = "nodejs", or use the Atlas Data API on Edge |
$vectorSearch returns nothing | Create the vector search index first; match numDimensions to the embedding model; ANN indexing takes a moment to build |
Aggregation is slow (COLLSCAN) | Index the early $match/$sort fields; confirm IXSCAN via explain |
Transaction WriteConflict / aborts | Use withTransaction (retries transient errors); reduce contention or embed data to avoid the transaction |
| Choosing Mongo vs the house default | Consult the codeAmani-tech-stack MCP first — prefer Neon Postgres + jsonb unless a true document model is required |