Back to blog

I dropped Mongoose and I'm not going back

7 min read

You npm install mongoose because that’s what every tutorial does. Every Express + MongoDB guide starts the same way: install Mongoose, define a schema, create a model, call .save(). It’s the default path. I followed it too, for years. On Prestonly — Vue 2 frontend, Node.js backend, MongoDB — I used Mongoose for everything. It was fine. It worked. I never questioned it until I started a new project and decided to try the native driver with Zod instead.

I’m not going back.

The type situation is what pushed me

If you’re using TypeScript, Mongoose’s type story is… getting there. But you end up defining your types in at least two places. There’s the Mongoose schema. There’s the TypeScript interface. And then there’s the document type that Mongoose actually returns, which isn’t quite your interface because it’s wrapped in Mongoose’s document class with all its methods attached. You spend time making these align. It’s not hard work. It’s just friction that shouldn’t exist.

The thing is — in a monorepo where the frontend and backend share types, Mongoose schemas are stuck on the backend. The frontend can’t import them. So you define validation rules twice and they drift apart. That’s what actually pushed me to try something else. Not the other stuff.

There’s also the magic — hooks, casting, virtuals, population — and I’ve lost hours debugging pre-save hooks that were silently modifying fields. But honestly I could’ve lived with that. The shared schema problem is the one that matters in a TypeScript monorepo.

What the Zod + native driver setup looks like

Zod schemas live in a shared package. Both apps import from the same place.

// packages/shared/schemas/questionSchema.ts
import { z } from 'zod';

export const questionSchema = z.object({
  id: z.string(),
  area: areaEnum,
  level: levelEnum,
  title: z.string(),
  problemStatement: z.string(),
  suggestedAnswer: z.string(),
  audioUrl: z.string().nullable(),
  acceptanceCriteria: z.array(
    z.object({ id: z.string(), description: z.string() }),
  ),
  tags: z.array(z.string()),
  estimatedFrequency: z.number().int().min(1).max(5).default(3),
  source: z.enum(['seed', 'user_custom']).default('seed'),
  createdAt: z.date(),
  updatedAt: z.date(),
});

export type Question = z.infer<typeof questionSchema>;

That Question type is the same one the frontend uses to render the UI and the backend uses to type the MongoDB collection. One definition. If I add a field or change a constraint, TypeScript catches every place that needs updating — in the API layer, the repository, the React components. No drift between what the database stores and what the UI expects.

On the backend, the repository layer uses the native driver directly. Here’s what a real repository looks like — not a simplified example:

// apps/backend/src/repositories/questionRepository.ts
import type { Collection, WithId } from 'mongodb';
import { getDb } from '../lib/db.js';

function collection(): Collection<QuestionDoc> {
  return getDb().collection<QuestionDoc>('questions');
}

export async function findByAreaAndLevel(
  area: string,
  level: string,
): Promise<WithId<QuestionDoc>[]> {
  return collection()
    .find({ area, level })
    .sort({ estimatedFrequency: -1 })
    .toArray();
}

export async function createQuestion(
  doc: QuestionDoc,
): Promise<WithId<QuestionDoc>> {
  const result = await collection().insertOne(doc);
  return { ...doc, _id: result.insertedId };
}

No model instances, no .save(), no document methods. It’s just functions that take data and return data. The collection is typed with Collection<QuestionDoc>, so you get autocompletion on filters and projections, but there’s no abstraction layer between you and the query.

The tRPC router ties it together. Zod validates the input, the service calls the repository, and the response flows back typed end-to-end:

// apps/backend/src/routers/question.ts
export const questionRouter = router({
  byId: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return questionService.getQuestionById(input.id);
    }),
});

Validation happens at the boundary — when data comes in through tRPC procedures, Zod validates it. The database layer trusts that the data is already valid. MongoDB doesn’t re-validate. Zod handles validation. MongoDB handles storage. That’s it.

What improved

Type safety end-to-end. Same Zod schema validates the tRPC input, generates the TypeScript type, and types the MongoDB collection. If I add a field, TypeScript tells me everywhere that needs updating. No drift. This is the big one. Everything else is a nice-to-have compared to this.

Simpler debugging. No middleware chain, no hooks, no casting. The query does what the query says. Smaller bundle — matters on serverless where cold starts are real. Faster onboarding — it’s just MongoDB operations and Zod schemas, both well-documented.

What I miss

Population. Mongoose’s .populate() is convenient. With the native driver you’re writing $lookup aggregation stages yourself. It looks like this:

const questionsWithProgress = await db
  .collection('questionProgress')
  .aggregate([
    { $match: { userId } },
    {
      $lookup: {
        from: 'questions',
        localField: 'questionId',
        foreignField: '_id',
        as: 'question',
      },
    },
    { $unwind: '$question' },
  ])
  .toArray();

That’s not hard, but it’s more code than .populate('question'). And the typing on aggregation pipelines is basically any — you lose all the type safety you gained elsewhere. I usually end up writing a mapper function after the aggregation to parse the result back into a typed shape, which feels redundant. I keep thinking there should be a lightweight wrapper library for this that doesn’t bring the rest of Mongoose along, but I haven’t found one that I actually like.

Defaults and timestamps. I handle createdAt and updatedAt manually now in the repository layer. It’s scattered across functions — here’s what it actually looks like in practice:

// In createPending:
await collection().insertOne({
  ...data,
  status: 'pending',
  createdAt: new Date(),
});

// In updateQuestionContent:
await collection().updateOne(
  { _id: new ObjectId(id) },
  { $set: { ...fields, updatedAt: new Date() } },
);

A few lines each time. More explicit — you always know where a timestamp is set — but it’s also the kind of thing where I wonder if I’m just reinventing what Mongoose already solved cleanly. With Mongoose you’d set timestamps: true on the schema and never think about it again. Here I have to remember to add updatedAt: new Date() in every update operation. I’ve forgotten it once already and only noticed because I was looking at the data in Compass and wondering why updatedAt was stale.

Middleware hooks I don’t miss. I moved that logic into service functions and the architecture is cleaner for it. But I acknowledge that’s partly a matter of taste.

The migration wasn’t really a migration

I should clarify — I didn’t migrate Prestonly from Mongoose to the native driver. That would’ve been a different story. Prestonly still runs Mongoose and probably always will. I just started the new project, Prepovo, without it.

Starting fresh made it easy. No existing models to port, no query syntax to translate, no risk of breaking production. I installed mongodb instead of mongoose, wrote the first repository function, and the pattern was obvious from there: export a collection() helper, write plain functions, return typed results.

The one thing that caught me off guard was how much Mongoose does silently. On Prestonly I never thought about default values — Mongoose schemas handled that. On Prepovo I’d insert a document and then wonder why a field was undefined instead of its default. Zod has .default() but that only applies during .parse(). If you construct an object manually and skip the parse step, you get whatever you put in. That bit me early and then I got used to it. Now I just always parse through Zod before inserting. The discipline is good but it took a few “why is this null” moments to build.

The other surprise: MongoDB native driver’s TypeScript support is actually solid. Collection<T> gives you typed filters, typed update operators, typed projections. I expected to miss Mongoose’s model methods — .findById(), .findOneAndUpdate() — but the native equivalents are nearly identical. The API surface is smaller, which turns out to be a feature.

What I genuinely can’t compare is migrating an existing project. Ripping Mongoose out of Prestonly would mean rewriting every model, every query, every pre-save hook. The hooks are the scary part — you’d have to audit every one, figure out what business logic is hiding in there, and move it somewhere explicit. For Prestonly that’s a month of work with no user-facing value. Not worth it.

When I’d still reach for Mongoose

Quick prototype, no TypeScript, no monorepo, just need CRUD working fast. Mongoose is still faster to set up for that. The Zod advantage only exists when you’re sharing schemas across a boundary.

Also — and I keep going back and forth on this — if your app has a lot of document relationships and you’re constantly joining data, the $lookup boilerplate gets old. Mongoose’s population isn’t magic-free but it’s less code. For a data-heavy app with 15 collections that all reference each other, I’d probably reach for Mongoose again. Prepovo has maybe 5 collections with simple relationships. The native driver handles that fine.

For a TypeScript monorepo with tRPC? The native driver does what you tell it to do. Zod validates what you tell it to validate. No magic. That turned out to be enough.

Ready to practice?

Start explaining concepts out loud and get AI-powered feedback. 5 minutes a day builds real skill.

Start practicing for free