Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Unique slug generation

default discord avatar
johnboylesingfieldlast year
4

Consider the

formatSlug()

function in the official website template:



import type { FieldHook } from 'payload/types'

const format = (val: string): string =>
  val
    .replace(/ /g, '-')
    .replace(/[^\w-]+/g, '')
    .toLowerCase()

const formatSlug =
  (fallback: string): FieldHook =>
  ({ operation, value, originalDoc, data }) => {
    if (typeof value === 'string') {
      return format(value)
    }

    if (operation === 'create') {
      const fallbackData = data?.[fallback] || originalDoc?.[fallback]

      if (fallbackData && typeof fallbackData === 'string') {
        return format(fallbackData)
      }
    }

    return value
  }

export default formatSlug


This is great, but I can create two posts with the same title and two identical slugs will be created. Of course, I can use

required: true

to catch this behavior, but the function will return an error. This is also not very intuitive for the user.



Is there a way to modify the

formatslug()

function to make it able to generate a "-${number}" at the end of a non-unique slug, thus making sure that it will succeed everytime? WordPress has something like that for their slug creation.



In other words, how can I gracefully check if a title is unique inside the

formatslug()

function?



Thanks for your help.

  • default discord avatar
    veganrunnerlast year

    You could do a

    payload.db.collections['collection-name'].findOne({slug: "your-slug"})

    then if there is already a document you could append a random string to the end.

  • default discord avatar
    johnboylesingfieldlast year

    - I added more rules to the

    format()

    function to cover edge cases.


    -

    isTitleFound()

    checks if a slug is found that correspond to the slugified title.


    -

    getUniqueSlug()

    loops check if

    isTitleFound()

    is true. If it is, it add a

    -${number}

    or increment the

    {number}

    if one already exists.



    Here is the final code.



    import type { FieldHook } from "payload/types";
    import payload from "payload";
    
    const format = (val: string): string =>
      val
        .normalize("NFKD")
        .replace(/[\u0300-\u036f]/g, "")
        .trim()
        .toLowerCase()
        .replace(/[^a-z0-9 -]/g, "")
        .replace(/\s+/g, "-")
        .replace(/-+/g, "-")
        .replace(/^-/, "")
        .replace(/-$/, "");
    
    async function isTitleFound(title: string, collection: string) {
      const titleFound = await payload.find({
        collection: collection,
        where: {
          slug: {
            equals: title,
          },
        },
      });
    
      if (titleFound.docs.length > 0) return true;
      return false;
    }
    
    async function getUniqueSlug(slug: string, collection: string) {
      let i = 2;
      let isFound = await isTitleFound(slug, collection);
      const regex = /^.*-\d+$/;
    
      while (isFound) {
        if (regex.test(slug)) {
          const match = slug.match(regex);
          if (match) {
            i = parseInt(match[0].split("-").pop()) + 1;
          }
          slug = slug.replace(/\d+$/, "");
          slug += `${i}`;
        } else {
          slug += `-${i}`;
        }
        isFound = await isTitleFound(slug, collection);
      }
    
      return slug;
    }
    
    const formatSlug =
      (collection: string, fallback: string): FieldHook =>
      ({ operation, value, originalDoc, data }) => {
        if (typeof value === "string")
          return getUniqueSlug(format(value), collection);
    
        if (operation === "create") {
          const fallbackData = data?.[fallback] || originalDoc?.[fallback];
    
          if (fallbackData && typeof fallbackData === "string") {
            return getUniqueSlug(format(fallbackData), collection);
          }
        }
    
        return value;
      };
    
    export default formatSlug;


    Maybe Payload website template should be updated with something similar. Thanks to

    @756246038414622770

    for the tip : )

  • default discord avatar
    brianjmlast year

    Can you submit a PR or issue?

  • default discord avatar
    modgy.last year

    I found an issue with the above code and thought it worth sharing here. Every time I hit save, I get a new number appended to the slug as it's finding this document in the collection and treating it as a duplicate, so a new number is constantly being added.



    I think a way to avoid this would be to pass in the document ID into the

    isTitleFound()

    function, something like this:



    async function isTitleFound(title: string, collection: string, id: string) {
      const titleFound = await payload.find({
        collection,
        where: {
          slug: {
            equals: title,
          },
          id: {
            not_equals: id,
          },
        },
      });
    
      if (titleFound.docs.length > 0) return true;
      return false;
    }


    but now I'm just trying to figure out how I can pass the document ID from the collection to this function 🙂



    Found it!



    It turns out the document ID is available from the

    originalDoc

    value:


    const { id } = originalDoc;

    So I can pass that to

    getUniqueSlug()

    and then to

    isTitleFound()

    to check that the

    find()

    function excludes documents where the ID matches the one you're updating. Complete code below:



    import payload from "payload";
    import { FieldHook } from "payload/types";
    import slugify from "slugify";
    
    const format = (val: string): string =>
      slugify(val, {
        lower: true,
        strict: true,
      });
    
    async function isTitleFound(slug: string, collection: string, id: string) {
      const titleFound = await payload.find({
        collection,
        where: {
          slug: {
            equals: slug,
          },
          id: {
            not_equals: id,
          },
        },
      });
    
      if (titleFound.docs.length > 0) return true;
      return false;
    }
    
    async function getUniqueSlug(slug: string, collection: string, id: string) {
      let i = 2;
      let isFound = await isTitleFound(slug, collection, id);
      const regex = /^.*-\d+$/;
    
      while (isFound) {
        if (regex.test(slug)) {
          const match = slug.match(regex);
          if (match) {
            i = parseInt(match[0].split("-").pop()) + 1;
          }
          slug = slug.replace(/\d+$/, "");
          slug += `${i}`;
        } else {
          slug += `-${i}`;
        }
        isFound = await isTitleFound(slug, collection, id);
      }
    
      return slug;
    }
    
    const formatSlug =
      (collection: string, fallback: string): FieldHook =>
      ({ operation, value, originalDoc, data }) => {
        const { id } = originalDoc;
    
        if (typeof value === "string") {
          return getUniqueSlug(format(value), collection, id);
        }
    
        if (operation === "create") {
          const fallbackData = data?.[fallback] || originalDoc?.[fallback];
    
          if (fallbackData && typeof fallbackData === "string") {
            return getUniqueSlug(format(fallbackData), collection, id);
          }
        }
    
        return value;
      };
    
    export default formatSlug;
Star on GitHub

Star

Chat on Discord

Discord

online

Can't find what you're looking for?

Get dedicated engineering support directly from the Payload team.