Defining a Mongoose Schema — Fields, Types and Options

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
  }
);
Note: By default Mongoose operates in strict mode — any field in a document that is not declared in the schema is silently stripped before saving. This is a safety feature: if a React form accidentally sends extra fields (like an injected 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.
Tip: Add 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.
Warning: The 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' }

🧠 Test Yourself

Your API returns user objects and you notice the password hash is included in every response. You have select: false on the password field. What is the likely cause?