A Mongoose schema is the blueprint for a MongoDB collection. It defines every field in your documents — their names, data types, validation rules, default values, string transformers, and computed properties — before any data is ever stored. Writing a well-designed schema protects your database from bad data, makes your API behaviour predictable, and documents your data model in code. In this lesson you will build the complete User and Post schemas for the MERN Blog application, covering every schema option you will use in a production project.
Schema Definition Syntax
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema(
{
// ── Field definition ───────────────────────────────────────────────────
// Shorthand — just the type
title: String,
// Full definition — type + options
title: {
type: String, // BSON type
required: true, // must be present
trim: true, // remove leading/trailing whitespace
minlength: 3, // minimum string length
maxlength: 200, // maximum string length
default: 'Untitled', // value if not supplied
},
},
{
// ── Schema options ─────────────────────────────────────────────────────
timestamps: true, // auto adds createdAt and updatedAt
versionKey: false, // remove __v field (document version key)
toJSON: { virtuals: true }, // include virtuals in JSON output
toObject: { virtuals: true }, // include virtuals in plain object output
}
);
role: 'admin'), strict mode removes them before they reach MongoDB. You can disable strict mode per schema with { strict: false } but this is almost never the right choice.versionKey: false to your schema options to remove the __v (version key) field that Mongoose adds to every document by default. The version key is used internally by Mongoose’s optimistic concurrency control for arrays, but most MERN applications do not need it and its presence in API responses is confusing. If you do use array update operations heavily, keep it.required validator in Mongoose only runs on save() and create(). It does NOT run automatically on findByIdAndUpdate() unless you pass { runValidators: true }. This is a very common source of “schema says required but null values are appearing in production” bugs. Always include runValidators: true in all update operations.The User Schema
// server/src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [50, 'Name cannot exceed 50 characters'],
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true, // creates a unique index in MongoDB
lowercase: true, // auto-converts to lowercase before saving
trim: true,
match: [
/^\S+@\S+\.\S+$/,
'Please provide a valid email address',
],
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
select: false, // IMPORTANT: never return password field by default
},
role: {
type: String,
enum: ['user', 'editor', 'admin'],
default: 'user',
},
avatar: {
type: String,
default: null,
},
bio: {
type: String,
maxlength: [300, 'Bio cannot exceed 300 characters'],
default: '',
},
isEmailVerified: {
type: Boolean,
default: false,
},
emailVerifyToken: { type: String, select: false },
emailVerifyExpires: { type: Date, select: false },
passwordResetToken: { type: String, select: false },
passwordResetExpires: { type: Date, select: false },
},
{
timestamps: true,
versionKey: false,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// Hash password before saving
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next(); // only hash if password changed
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Instance method — compare plaintext password with stored hash
userSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
// Virtual — full name (if you add firstName/lastName fields)
userSchema.virtual('postCount', {
ref: 'Post',
localField: '_id',
foreignField: 'author',
count: true,
});
module.exports = mongoose.models.User || mongoose.model('User', userSchema);
The Post Schema
// server/src/models/Post.js
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, 'Post title is required'],
trim: true,
minlength: [3, 'Title must be at least 3 characters'],
maxlength: [200, 'Title cannot exceed 200 characters'],
},
slug: {
type: String,
unique: true,
lowercase: true,
trim: true,
},
body: {
type: String,
required: [true, 'Post body is required'],
minlength: [10, 'Body must be at least 10 characters'],
},
excerpt: {
type: String,
maxlength: [500, 'Excerpt cannot exceed 500 characters'],
default: '',
},
coverImage: { type: String, default: null },
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: [true, 'Author is required'],
},
tags: { type: [String], default: [] },
published: { type: Boolean, default: false },
featured: { type: Boolean, default: false },
viewCount: { type: Number, default: 0, min: 0 },
deletedAt: { type: Date, default: null },
},
{
timestamps: true,
versionKey: false,
toJSON: { virtuals: true },
toObject: { virtuals: true },
}
);
// Auto-generate slug from title
postSchema.pre('save', function (next) {
if (this.isModified('title') || this.isNew) {
this.slug = this.title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim('-');
}
next();
});
// Auto-exclude soft-deleted documents from all find queries
postSchema.pre(/^find/, function (next) {
if (!this.getOptions().includeDeleted) this.where({ deletedAt: null });
next();
});
// Indexes
postSchema.index({ slug: 1 }, { unique: true });
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ tags: 1 });
postSchema.index({ published: 1, createdAt: -1 });
postSchema.index({ title: 'text', body: 'text' });
module.exports = mongoose.models.Post || mongoose.model('Post', postSchema);
Schema Field Options Reference
| Option | Type | Effect |
|---|---|---|
type |
SchemaType | The field’s data type |
required |
bool / [bool, msg] | Field must be present and non-null |
unique |
bool | Creates a unique index in MongoDB |
default |
value / function | Value if field is not supplied |
trim |
bool | Strip leading/trailing whitespace (String only) |
lowercase |
bool | Auto-convert to lowercase before saving (String) |
uppercase |
bool | Auto-convert to uppercase (String) |
minlength |
int / [int, msg] | Minimum string length validator (String) |
maxlength |
int / [int, msg] | Maximum string length validator (String) |
min |
num / [num, msg] | Minimum value validator (Number / Date) |
max |
num / [num, msg] | Maximum value validator (Number / Date) |
enum |
array / [array, msg] | Value must be one of the listed options |
match |
[regex, msg] | String must match the regex |
select |
bool | false = exclude from query results by default |
ref |
string | Model name for populate() resolution |
index |
bool | Create a regular index on this field |
Common Mistakes
Mistake 1 — Forgetting select: false on sensitive fields
❌ Wrong — password returned in every user query response:
password: { type: String, required: true }
// await User.find() returns { _id, name, email, password: '$2b$12$...' }
// Password hash sent to React — security risk
✅ Correct — use select: false so password is never returned unless explicitly requested:
password: { type: String, required: true, select: false }
// await User.find() returns { _id, name, email } — no password ✓
// await User.findOne({ email }).select('+password') — opt in explicitly when needed
Mistake 2 — Using unique: true instead of a compound index
❌ Wrong — slug must be unique only within a user’s posts, but a global unique index is set:
slug: { type: String, unique: true } // global uniqueness — two users can't have same slug
✅ Correct — create a compound index for the right scope:
postSchema.index({ author: 1, slug: 1 }, { unique: true }); // unique per author ✓
Mistake 3 — Not using a custom required message
❌ Wrong — default required error message is vague:
title: { type: String, required: true }
// ValidationError: Post validation failed: title: Path `title` is required.
✅ Correct — provide a descriptive message your API can return to React:
title: { type: String, required: [true, 'Post title is required'] }
// ValidationError: Post validation failed: title: Post title is required. ✓
Quick Reference
| Task | Schema Syntax |
|---|---|
| Required field | field: { type: String, required: [true, 'msg'] } |
| Default value | field: { type: Boolean, default: false } |
| Unique constraint | field: { type: String, unique: true } |
| Exclude from queries | field: { type: String, select: false } |
| Allowed values | role: { type: String, enum: ['user','admin'] } |
| Auto timestamps | new Schema({...}, { timestamps: true }) |
| Remove __v | new Schema({...}, { versionKey: false }) |
| Reference | author: { type: ObjectId, ref: 'User' } |