← Back to dashboard
mongodbdatabasefresh

MongoDB Integration Guide

Text
███╗   ███╗ ██████╗ ███╗   ██╗ ██████╗  ██████╗ ██████╗ ██████╗
████╗ ████║██╔═══██╗████╗  ██║██╔════╝ ██╔═══██╗██╔══██╗██╔══██╗
██╔████╔██║██║   ██║██╔██╗ ██║██║  ███╗██║   ██║██║  ██║██████╔╝
██║╚██╔╝██║██║   ██║██║╚██╗██║██║   ██║██║   ██║██║  ██║██╔══██╗
██║ ╚═╝ ██║╚██████╔╝██║ ╚████║╚██████╔╝╚██████╔╝██████╔╝██████╔╝
╚═╝     ╚═╝ ╚═════╝ ╚═╝  ╚═══╝ ╚═════╝  ╚═════╝ ╚═════╝ ╚═════╝

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-stack MCP (search_guides / get_guide) and confirm the house Neon/Postgres default — including jsonb — 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

MCP note: an official MongoDB MCP server exists and ships with the house plugin stack — it can find, aggregate, inspect collection-schema, manage create-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:

  1. 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).
  2. Create a database user (username + password) under Database Access.
  3. 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/0 and 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.
  4. 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:

Bash
mongodb+srv://<user>:<urlEncodedPassword>@cluster0.xxxxx.mongodb.net/<dbName>?retryWrites=true&w=majority&appName=acme-app

Gotcha: the password must be URL-encoded in the connection string. A literal @, :, /, or # in the password will break parsing — encode P@ss:w0rd as P%40ss%3Aw0rd. Store the assembled URI in MONGODB_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

TypeScript
// 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 requests await the same in-flight connection instead of each opening a pool. Never call client.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:

TypeScript
// 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.

TypeScript
// 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:

TypeScript
// 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 ($jsonSchema validators) 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:

TypeScript
// 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 $match and $sort fields. Aggregations are only fast when the early $match/$sort stages hit an index; otherwise Atlas does a full collection scan. Use the MCP explain tool (or db.collection.explain()) to confirm an IXSCAN, not a COLLSCAN.


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:

TypeScript
// 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:

JSON
{
  "fields": [
    {
      "type": "vector",
      "path": "embedding",
      "numDimensions": 1536,
      "similarity": "cosine"
    },
    { "type": "filter", "path": "tenantId" }
  ]
}

Then query with $vectorSearch as the first pipeline stage:

TypeScript
// 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 storeWhen 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 SearchAlready on MongoDB; want embeddings beside the source documents; one datastore

Multi-tenant safety: always pass a filter (e.g. tenantId) in $vectorSearch and index it as a filter field — this is the same isolation discipline as a WHERE tenant_id = ... clause. Never return another tenant's vectors.


Environment Variables

Bash
# 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=app

Add these to ENV_MASTER.md and each project's .env.example. MONGODB_URI is a server-only secret — it contains credentials; never expose it via NEXT_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 CaseApproach
Default relational databaseNeon Postgres + Drizzle (house default) — use MongoDB only for true document needs
Semi-structured / occasional nestingPostgres jsonb on Neon before reaching for MongoDB
Flexible / evolving / per-tenant schemaMongoDB Atlas + Mongoose Schema.Types.Mixed
Serverless connection (Vercel/Netlify)Cached client/connection promise on globalThis (never connect per request)
Input validationZod at the edge + Mongoose schema + collection $jsonSchema validator
Analytics / joins / rollupsAggregation pipeline ($match$lookup$group$project)
Atomic multi-document writessession.withTransaction(...) (only when embedding can't avoid it)
RAG / semantic search on Mongo dataAtlas Vector Search $vectorSearch (else Neon+pgvector or Pinecone)
Inspect schema / indexes / run queriesMongoDB MCP (collection-schema, collection-indexes, find, aggregate, explain)

Troubleshooting

IssueFix
Connection-pool exhaustion on serverlessCache the connect() promise on globalThis; never new MongoClient() per request, never close() in a handler
MongoParseError / auth fails on connectURL-encode the password in MONGODB_URI (@%40, :%3A); verify the DB user under Database Access
MongoServerSelectionError / timeoutAdd 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 modelGuard model creation with models.X ?? model("X", schema) so HMR/warm invocations reuse it
MongooseError: Operation buffering timed outSet bufferCommands: false and await dbConnect() before any query; the connection wasn't established
Runs on Edge / Workers and failsDriver & Mongoose are Node-only — set export const runtime = "nodejs", or use the Atlas Data API on Edge
$vectorSearch returns nothingCreate 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 / abortsUse withTransaction (retries transient errors); reduce contention or embed data to avoid the transaction
Choosing Mongo vs the house defaultConsult the codeAmani-tech-stack MCP first — prefer Neon Postgres + jsonb unless a true document model is required