Package Exports
- @classytic/mongoose-slug-plugin
Readme
mongoose-slug-plugin
A robust Mongoose plugin for automatic slug generation with intelligent duplicate handling.
Features
- ✅ Mongoose 9 Compatible (with backward compatibility for v6/v7/v8)
- ✅ Automatic slug generation from any source field (e.g., title, name)
- ✅ Duplicate handling with incremental suffixes (Amazon-style: "product", "product-1", "product-2")
- ✅ Global uniqueness across your entire platform (like Amazon, eBay)
- ✅ Works on create and update operations
- ✅ Preserves manual slugs if explicitly provided
- ✅ Customizable slugify options
- ✅ Production-ready with comprehensive error handling
Installation
The package is already included in your project. The required peer dependency slugify should be installed:
npm install slugifyQuick Start
Basic Usage
import mongoose from 'mongoose';
import slugPlugin from '#lib/mongoose-slug-plugin/index.js';
const productSchema = new mongoose.Schema({
title: { type: String, required: true },
slug: { type: String, required: true, lowercase: true },
});
// Add the plugin
productSchema.plugin(slugPlugin, {
sourceField: 'title'
});
const Product = mongoose.model('Product', productSchema);
// Create a product
const product = await Product.create({
title: 'Awesome Product'
});
console.log(product.slug); // "awesome-product"E-commerce Usage (Global Uniqueness)
For e-commerce platforms like Amazon, eBay, etc., slugs must be globally unique across all organizations:
const productSchema = new mongoose.Schema({
organizationId: { type: Schema.Types.ObjectId, required: true },
title: { type: String, required: true },
slug: { type: String, lowercase: true },
});
// Global unique index on slug
productSchema.index({ slug: 1 }, { unique: true });
productSchema.plugin(slugPlugin, {
sourceField: 'title'
// No scopeFields - globally unique!
});
// Organization A creates "wireless-headphones"
await Product.create({
organizationId: orgA,
title: 'Wireless Headphones'
// slug: "wireless-headphones"
});
// Organization B tries same title → gets incremented slug
await Product.create({
organizationId: orgB,
title: 'Wireless Headphones'
// slug: "wireless-headphones-1"
});
// URLs are globally unique across platform:
// → yourplatform.com/products/wireless-headphones
// → yourplatform.com/products/wireless-headphones-1API
Plugin Options
schema.plugin(slugPlugin, options);| Option | Type | Default | Description |
|---|---|---|---|
sourceField |
string |
required | Field to generate slug from (e.g., 'title', 'name') |
slugField |
string |
'slug' |
Field to store the generated slug |
scopeFields |
string[] |
[] |
Optional: Fields to scope uniqueness (e.g., ['category']). Omit for global uniqueness (recommended for e-commerce). |
slugifyOptions |
object |
See below | Custom slugify options |
updateOnChange |
boolean |
false |
Regenerate slug when source field changes on updates |
Default Slugify Options
{
lower: true, // Convert to lowercase
strict: true, // Strip special characters
trim: true, // Trim leading/trailing chars
remove: /[*+~.()'"!:@]/g // Remove these characters
}Examples
Example 1: Basic Product
const productSchema = new Schema({
title: String,
slug: String,
});
productSchema.plugin(slugPlugin, {
sourceField: 'title'
});
await Product.create({ title: 'Coffee Maker' });
// slug: "coffee-maker"
await Product.create({ title: 'Coffee Maker' });
// slug: "coffee-maker-1"
await Product.create({ title: 'Coffee Maker' });
// slug: "coffee-maker-2"Example 2: Multi-tenant Course Platform
const courseSchema = new Schema({
organizationId: { type: Schema.Types.ObjectId, required: true },
title: String,
slug: String,
});
courseSchema.plugin(slugPlugin, {
sourceField: 'title',
scopeFields: ['organizationId']
});
// Each organization can have its own "intro-to-programming" course
await Course.create({
organizationId: org1,
title: 'Intro to Programming'
// slug: "intro-to-programming"
});
await Course.create({
organizationId: org2,
title: 'Intro to Programming'
// slug: "intro-to-programming" (different org)
});Example 3: Custom Slug Field
const blogSchema = new Schema({
title: String,
urlSlug: String,
});
blogSchema.plugin(slugPlugin, {
sourceField: 'title',
slugField: 'urlSlug'
});
await Blog.create({ title: 'My First Post' });
// urlSlug: "my-first-post"Example 4: Manual Slug Override
// If you provide a slug manually, the plugin won't generate one
await Product.create({
title: 'Special Product',
slug: 'custom-slug-123'
});
// slug: "custom-slug-123" (manual slug preserved)Example 5: Update with Slug Regeneration
const productSchema = new Schema({
title: String,
slug: String,
});
productSchema.plugin(slugPlugin, {
sourceField: 'title',
updateOnChange: true // Regenerate slug on title change
});
const product = await Product.create({ title: 'Old Name' });
// slug: "old-name"
await product.updateOne({ title: 'New Name' });
// slug: "new-name" (regenerated)Example 6: Multiple Scope Fields
const productSchema = new Schema({
organizationId: Schema.Types.ObjectId,
category: String,
name: String,
slug: String,
});
productSchema.plugin(slugPlugin, {
sourceField: 'name',
scopeFields: ['organizationId', 'category']
});
// Unique slug per organization AND category
await Product.create({
organizationId: org1,
category: 'electronics',
name: 'Laptop'
// slug: "laptop"
});
await Product.create({
organizationId: org1,
category: 'furniture',
name: 'Laptop'
// slug: "laptop" (different category)
});
await Product.create({
organizationId: org1,
category: 'electronics',
name: 'Laptop'
// slug: "laptop-1" (same org + category)
});How It Works
On Create: When a new document is created, the plugin:
- Checks if a slug is manually provided → uses it as-is
- Otherwise, generates a base slug from the
sourceField - Checks for duplicates within the specified scope
- Appends
-1,-2, etc. if duplicates exist
On Update: When updating via
save()orfindOneAndUpdate():- By default, doesn't regenerate slug
- If
updateOnChange: true, regenerates when source field changes - Checks for duplicates and handles them appropriately
Duplicate Resolution:
- Uses regex pattern matching to find existing slugs
- Extracts numbers from slugs like
"product-5" - Finds the highest number and increments by 1
- Example sequence:
"product"→"product-1"→"product-2"
Best Practices
1. Add Unique Index
Always add a unique index on the slug field (and scope fields):
// Single tenant
productSchema.index({ slug: 1 }, { unique: true });
// Multi-tenant
productSchema.index({ organizationId: 1, slug: 1 }, { unique: true });2. Use Scoped Uniqueness
For multi-tenant apps, always use scopeFields:
schema.plugin(slugPlugin, {
sourceField: 'title',
scopeFields: ['organizationId'] // ✅ Correct
});
// ❌ Without scope, slugs would be globally unique across all organizations3. Don't Auto-Update Slugs
Unless you have a specific reason, keep updateOnChange: false (default):
// ✅ Recommended: Slugs are stable (good for SEO)
schema.plugin(slugPlugin, {
sourceField: 'title',
updateOnChange: false
});
// ❌ Avoid: Changing title breaks existing URLs
schema.plugin(slugPlugin, {
sourceField: 'title',
updateOnChange: true
});Error Handling
The plugin throws errors in these cases:
// Missing sourceField
schema.plugin(slugPlugin, {});
// Error: slugPlugin requires a sourceField option
// Non-existent sourceField
schema.plugin(slugPlugin, { sourceField: 'nonExistent' });
// Error: sourceField 'nonExistent' does not exist in schema
// Empty source value
await Product.create({ title: '' });
// Error: Cannot generate slug: title is required
// Empty generated slug
await Product.create({ title: '!@#$%' });
// Error: Cannot generate slug from title: "!@#$%"Integration with Existing Models
Product Model
// modules/product/product.model.js
import slugPlugin from '#lib/mongoose-slug-plugin/index.js';
productSchema.plugin(slugPlugin, {
sourceField: 'title',
scopeFields: ['organizationId']
});Course Model
// modules/course/models/course.model.js
import slugPlugin from '#lib/mongoose-slug-plugin/index.js';
courseSchema.plugin(slugPlugin, {
sourceField: 'title',
scopeFields: ['organizationId']
});Landing Page Model
// modules/landing/landing.model.js
import slugPlugin from '#lib/mongoose-slug-plugin/index.js';
landingSchema.plugin(slugPlugin, {
sourceField: 'name',
scopeFields: ['organizationId']
});Testing
Test the plugin with various scenarios:
// Test 1: Basic slug generation
const p1 = await Product.create({ title: 'Test Product' });
expect(p1.slug).toBe('test-product');
// Test 2: Duplicate handling
const p2 = await Product.create({ title: 'Test Product' });
expect(p2.slug).toBe('test-product-1');
// Test 3: Scoped uniqueness
const p3 = await Product.create({
organizationId: org2,
title: 'Test Product'
});
expect(p3.slug).toBe('test-product'); // Different org
// Test 4: Manual slug
const p4 = await Product.create({
title: 'Test',
slug: 'custom'
});
expect(p4.slug).toBe('custom');
// Test 5: Special characters
const p5 = await Product.create({
title: 'Product w/ Special Chars! @2024'
});
expect(p5.slug).toBe('product-w-special-chars-2024');License
MIT
Contributing
This package is designed to be extracted as a standalone npm package in the future. Contributions welcome!
Support
For issues or questions, please contact the classytic development team.