Create a PayloadCMS Custom Slug Field

2025/07/24

A walkthrough of how to create a plugin for PayloadCMS (version 3.x)

I'm using PayloadCMS for this blog. When I create a post, I usually want it to have a slug that matches the post's title.

For example, for this article, where the title is 'Create a PayloadCMS Custom Slug Field', the slug should be 'create-a-payloadcms-custom-slug-field'.

I thought that this would be a perfect fit for a custom field.

Here's the things that I wanted:

  • set a text field's admin edit component,
  • indicate the source field for the component,
  • have that component watch for changes to the source,
  • generate a slug, replacing non-ASCII characters with '-',
  • allow the user to override this behaviour and create their own slug,
  • validate that the slug matches the desired pattern.

BTW: I'm skipping testing here to keep the examples short.

The Collection

The Postcollection:

export const Post: CollectionConfig = {
  fields: [
    {      
      name: 'title',
      type: 'text',
      required: true
    },
    {
      name: 'slug',
      type: 'text',
      admin: {
        components: {
          Field: {
            path: '/fields/SlugField',
            clientProps: {
              source: 'title'
            }
          }
        }
      },
      required: true
    },
    ...
  ]
}

Note that we add clientProps, with source: 'title', which then gets passed to our component, so we know which field we are slugifying.

Also, we're only changing the behaviour of our field, not the underlying data structure, so it's still type: 'text'.

path

It's worth noting the value of path (see docs).

The path you give should be relative to baseDir set in src/payload.config.ts, which is normally the src subdirectory.

We'll be creating src/fields/SlugField.tsx, so path is /fields/SlugField.

I found it strange at first as paths starting with / seem like they might be relative to project root.

But, as long as you remember that when payload is resolving the component path, the given path is prefixed with src, I suppose it makes sense.

First Working Version

I'll start with a minimal working version of the component. It just grabs the title field and sets its own value based on that, no validation, no errors, etc. Also it's in Javascript in order to skip the extra code entailed by Typescript.

'use client'

import React, { useEffect } from 'react'
import { TextInput, useField, useFormFields } from '@payloadcms/ui'

const slugify = value =>
  value
    .toLocaleLowerCase()
    .replace(/[^\-a-z0-9]/g, " ")
    .trim()
    .replace(/\s+/g, "-")

const SlugField = props => {
  const { source } = props
  const sourceField = useFormFields(([fields]) => (fields && fields[source]))
  const sourceValue = sourceField.value || ""
 
  const field = useField()  
  const { setValue, value } = field  

  useEffect(() => {
    if (!sourceValue) {
      return
    }
    const slug = slugify(sourceValue)
    setValue(slug)
  }, [
    setValue,
    sourceValue
  ])

  return <TextInput {...props} value={value} />
}
  
export default SlugField

The source value we passed to clientProps is passed in with the other props.

From that, we get the current value of the source field.

We then grab the current value of our Slug field itself.

We monitor when the source field's value changes via useEffect, and we slugify it and update our field.

The slugify function ensures we only end up with alphanumerics, plus -.

In the end we simply use Payload's TextInput.

The Full Version

'use client'

import React, { ChangeEvent, useEffect, useState } from 'react'
import { formatLabels } from 'payload/shared'
import { FieldError, TextInput, type TextInputProps, useField, useFormFields } from '@payloadcms/ui'

const slugify = (value: string) =>
  value
    .toLocaleLowerCase()
    .replace(/[^\-a-z0-9]/g, " ")
    .trim()
    .replace(/\s+/g, "-")

const isEmpty = (value: any) => value === null || value === undefined
const isBlank = (value: string | null | undefined) => isEmpty(value) || value === ''

interface ToLabelArgs {
  path: string,
  source: string,
  managed: boolean
}

const toLabel = ({ path, source, managed }: ToLabelArgs) => {
  const { singular } = formatLabels(path)
  if (managed) {
    const sourceLabel = formatLabels(source).singular
    return `${singular} (generated from '${sourceLabel}' field)`
  } else {
    return singular
  }
}

const validateSlug = (value: string | null) => {
  if (isBlank(value)) {
    return true
  }
  const slug = slugify(value as string)
  if (slug !== value) {
    return 'Slug must be lowercase, and can only contain letters, numbers and dashes'
  }
  return true
}

// `field` has been deprecated from the TextInputProps type, but it is the only
// way to get the `required` flag.
type DeprecatedFieldEntry = { required: boolean }
// The TextInputProps type has dual handling of onChange based on the value of hasMany
// If we accept both values, we are forced to have two rendering paths.
type SlugFieldInputProps = TextInputProps & { hasMany: false, source: string, field: DeprecatedFieldEntry }

const SlugField = (props: SlugFieldInputProps) => {
  const { path, source, field: { required } } = props
  const field = useField<string>({ path, validate: validateSlug })
  const { setValue, value, errorMessage } = field
  const showError = !!errorMessage
  const sourceField = useFormFields(([fields]) => (fields && fields[source]))
  const sourceValue = sourceField.value as string
  const slugifiedSourceValue = slugify(sourceValue || "")
  // We only set the slug if the user has not set it, or if the slugified
  // source value matches the current value.
  const startManaged = isBlank(value) || slugifiedSourceValue === value
  const [managed, setManaged] = useState(startManaged)
  const label = toLabel({ path, source, managed })

  useEffect(() => {
    if (!managed) {
      return
    }

    if (isBlank(sourceValue)) {
      return
    }

    const slug = slugify(sourceValue)
    setValue(slug)
  }, [
    managed,
    setValue,
    sourceValue,
    value
  ])

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value
    setManaged(isBlank(value))
    setValue(value)
  }
  const { field: _field, source: _source, ...noSource } = props

  const error = showError ? <FieldError message={errorMessage} path={path} showError={true} /> : undefined

  return (
    <TextInput
      {...noSource}
      label={label}
      onChange={handleChange}
      path={path}
      required={required}
      Error={error}
      showError={showError}
      value={value}
    />
  )
}

export default SlugField

There's a number of changes here.

We want a label that says 'Slug', not 'slug', so we use Payload's formatLabels. But, we also want to show if the field is required.

We also want to indicate whether we're generating the field's value or leaving up to the user. For this, I introduced the concept of "managed". If the field starts empty, or if its existing value matches what the slugify function produces, then we link this field to the source field. If not, the user has chosen to go their own way, so we don't update the field's value with a slugified version of the source.

The slug field in The slug field in "managed" state

We reset managed to true if the user clears the field, that way, if they change their mind, they can get back to the managed version.

The slug field in The slug field in "unmanaged" state

Getting the value of required brings us to the next point. As this is now Typescript, the component's type is an extended version of TextInputProps, to which we add source: string. Apart from source the props' field is the only place where the component can get a handle on the required property. Unfortunately, field has been deprecated as part of TextInputProps so we have to hack things and add it back. I've been unable to find another way of getting hold of required for a field, so I'm wondering what the official way is.

Conclusion

This example mixes some general concerns about creating custom components in PayloadCMS with others that are more specific to the slug field itself.

When I was creating my slug field, it was very hard to find up-to-date information, especially as Payload 3 has changed a lot compared to Payload 2, so I ended up reading a lot of source code to work out how things are done. I hope that making this example available will help someone else to avoid all that pain!