Easily slugify text in expressJS & mongoose

In this tutorial we'll learn how to easily slugify text in express & mongoose. 

You may be wondering why we're going to be using mongoose in this. Well, slugs need to be unique to the table that they're in. So if we have a table named blog_posts, and within that table we have a varchar column named post_slug, a value in the post_slug column of "hello-world" should only exist once. Having a slug named "hello-world-1" is perfectly acceptable, however.

At its core, slugifying a string is very simple. You take an input, put it to lowercase, split it into an array, then join it back into a string using hyphens.

Naturally, we also need to split out special characters. This can be done with a magical bit of regex.

It sounds like a lot, but the code is something like this:

const slugify = (textToSlugify) => {
    if (!textToSlugify) return '';

    const lowercaseText = textToSlugify.toLowerCase().replace(/[^a-zA-Z0-9 ]/g, '').replace(/ +(?= )/g, '');

    return lowercaseText.split(' ').join('-');
}

To go over this, first we ensure that we have actually received some text to slugify. 

Then the magic happens, we convert the text to lowercase and then using regex we get everything that isn't a letter, number, or space and delete it from the string. This gets rid of stuff like - / \ @ ' " etc. as we don't want to slugify that.

Cool, now we can generate slugs for any text we want, like so:

slugify('Hello world!!!');

This will now return hello-world to us.

Now we just need to ensure it's unique in the collection we're trying to put it into, this is where mongoose comes in.

Let's create a little schema, named BlogPost.

const mongoose = require('mongoose');

const BlogPostSchema = new mongoose.Schema({
    title: {
        type: String,
        min: 6,
        max: 255,
        required: true
    },
    slug: {
        type: String,
        required: true,
        unique: true,
    },
});

module.exports = mongoose.model('BlogPost', BlogPostSchema);

Just a small one so we can see what we're doing, so we have a title & a slug.

Whenever we want to slugify something, we'll want to check if that exact slug already exists within our collection. If so, it's no good because we've made it unique.

Let's create a new function to handle this.

const generateSlugFor = async (originalText, dbModel) => {
    const MAX_TRIES = 10;

    let slug = slugify(originalText);
    let i = 0;
    let existingSlug = await dbModel.exists({slug});

    while (existingSlug && i < MAX_TRIES) {
        const titleAppended = `${originalText} ${i}`;
        slug = slugify(titleAppended);
        existingSlug = await dbModel.exists({slug});
        i++;
    }
  
  	if (i === MAX_TRIES) {
      throw new Error('Generated slug is too generic, try a different string.');
    }

    return slug;
}

We now have a function that takes in 2 parameters. The original text, & a database model. The text is just a string, so Hello World!!! for example. The database model will be from our schema, where we export it with mongoose.model. In our case, BlogPost.

In our generateSlugFor function we first set a MAX_TRIES constant, as you can guess this is the maximum amount of times we should try to generate a slug. I've set it to 10.

We first generate a slug using our above slugify() function, & then we check if it exists in the collection by using the database model we pass through and just calling the exists method off of it.

If it doesn't exist, then that's great and we go all the way to the return and send our shiny new slug back.

If it does exist however, we need to start changing our input text a bit. We'll start a while loop, and our while loop will go on 2 conditions. The first condition is that the slug exists, and the second condition is whilst we're below our maximum tries.

Next we take our original string, and put an iteration number on it. For example Hello World!!! 0.

This then converts Hello World!!! 0 into hello-world-0, and check if that exists in the collection. It will keep going on, until it either finds one that doesn't exist, be that hello-world-9, or it reaches the maximum number of tries.

If we reach the maximum number of tries, we're simply gonna throw an error back saying that the title is too generic.

Here's the entire code we've written blocked together:

const slugify = (textToSlugify) => {
    if (!textToSlugify) return '';

    const lowercaseText = textToSlugify.toLowerCase().replace(/[^a-zA-Z0-9 ]/g, '').replace(/ +(?= )/g, '');

    return lowercaseText.split(' ').join('-');
}

const generateSlugFor = async (originalText, dbModel) => {
    const MAX_TRIES = 10;

    let slug = slugify(originalText);
    let i = 0;
    let existingSlug = await dbModel.exists({slug});

    while (existingSlug && i < MAX_TRIES) {
        const titleAppended = `${originalText} ${i}`;
        slug = slugify(titleAppended);
        existingSlug = await dbModel.exists({slug});
        i++;
    }

    return slug;
}

This works pretty well in practice, and is actually what I'm currently using on this blog to generate my slugs.

Whenever I create or update a post (if the title has changed) I simply call generateSlugFor(title, BlogPost) and get a nice slug to go along with my post.