Created
October 4, 2025 03:46
-
-
Save notflip/9794477c2ff88de618a61683c06a06f2 to your computer and use it in GitHub Desktop.
Auto slug field in PayloadCMS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { FieldHook } from "payload"; | |
| export const generateSlug = (val: string): string => | |
| val | |
| .replace(/ /g, "-") | |
| .replace(/[^\w-]+/g, "") | |
| .toLowerCase(); | |
| export const generateSlugHook = | |
| (fallback: string): FieldHook => | |
| ({ data, operation, value }) => { | |
| if (typeof value === "string") { | |
| return generateSlug(value); | |
| } | |
| // If the operation is create, use the fallback (title) field | |
| if (operation === "create" || !data?.slug) { | |
| const fallbackData = data?.[fallback] || data?.[fallback]; | |
| if (fallbackData && typeof fallbackData === "string") { | |
| return generateSlug(fallbackData); | |
| } | |
| } | |
| return value; | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { TextField } from "payload"; | |
| import { generateSlugHook } from "@/fields/slug/generateSlugHook"; | |
| import { validateSlugUniqueness } from "@/fields/slug/validateSlugUniqueness"; | |
| type SlugOptions = { | |
| collectionsToCheck?: string[]; | |
| autoResolveConflicts?: boolean; | |
| }; | |
| type Slug = (fieldToUse?: string, slugOverrides?: object, options?: SlugOptions) => [TextField]; | |
| export const slugField: Slug = (fieldToUse = "title", slugOverrides, options) => { | |
| const slugField: TextField = { | |
| name: "slug", | |
| type: "text", | |
| index: true, | |
| localized: true, | |
| label: "Slug", | |
| ...(slugOverrides || {}), | |
| hooks: { | |
| beforeValidate: [generateSlugHook(fieldToUse)], | |
| beforeChange: [validateSlugUniqueness(options || {})], | |
| }, | |
| admin: { | |
| position: "sidebar", | |
| // ...(slugOverrides?.admin || {}), | |
| description: | |
| "✋ Het wijzigen van de slug na publicatie kan bestaande links breken en zorgt ervoor dat bezoekers of zoekmachines de pagina niet meer kunnen vinden.", | |
| components: { | |
| Field: { | |
| path: "@/fields/slug/SlugComponent#SlugComponent", | |
| clientProps: { | |
| fieldToUse, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }; | |
| return [slugField]; | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { FieldLabel, TextInput, useDocumentInfo, useField, useFormFields } from "@payloadcms/ui"; | |
| import { TextFieldClientProps } from "payload"; | |
| import React, { useEffect } from "react"; | |
| import { generateSlug } from "./generateSlugHook"; | |
| type SlugComponentProps = { | |
| fieldToUse: string; | |
| } & TextFieldClientProps; | |
| export const SlugComponent: React.FC<SlugComponentProps> = (props) => { | |
| const { field, fieldToUse, path, readOnly } = props; | |
| const { value, setValue } = useField<string>({ path }); | |
| const { data, hasPublishedDoc } = useDocumentInfo(); | |
| const { label, admin } = field; | |
| // The value of the field we're listening to for the slug | |
| const targetFieldValue = useFormFields(([fields]) => { | |
| return fields[fieldToUse]?.value as string; | |
| }); | |
| useEffect(() => { | |
| // If the 'title' field has a value | |
| if (targetFieldValue) { | |
| const formattedSlug = generateSlug(targetFieldValue); | |
| // If there is no slug, or if the doc is not published and the slug has changed. | |
| if (!data?.slug || (!hasPublishedDoc && value !== formattedSlug)) { | |
| setValue(formattedSlug); | |
| } | |
| } | |
| }, [targetFieldValue, data]); | |
| return ( | |
| <div className="field-type"> | |
| <FieldLabel htmlFor={`field-${path}`} label={label} /> | |
| <TextInput value={value} onChange={setValue} path={path} readOnly={readOnly} /> | |
| <div className="field-description field-description-title">{`${admin?.description}`}</div> | |
| </div> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { FieldHook } from "payload"; | |
| type ValidateSlugUniquenessOptions = { | |
| collectionsToCheck?: string[]; | |
| autoResolveConflicts?: boolean; | |
| }; | |
| /** | |
| * Hook to validate unique slugs within specified collections | |
| */ | |
| export const validateSlugUniqueness = (options: ValidateSlugUniquenessOptions = {}): FieldHook => { | |
| const { collectionsToCheck = ["pages"], autoResolveConflicts = true } = options; | |
| return async ({ collection, req, value, originalDoc }) => { | |
| if (!collection || !value || typeof value !== "string") { | |
| return value; | |
| } | |
| // Check for conflicts only within the specified collections | |
| if (collectionsToCheck.includes(collection.slug)) { | |
| let finalSlug = value; | |
| let counter = 1; | |
| while (true) { | |
| const existingDoc = await req.payload.find({ | |
| collection: collection.slug, | |
| where: { | |
| slug: { equals: finalSlug }, | |
| ...(originalDoc?.id && { id: { not_equals: originalDoc.id } }), | |
| }, | |
| limit: 1, | |
| }); | |
| // No conflict found, use this slug | |
| if (existingDoc.docs.length === 0) { | |
| return finalSlug; | |
| } | |
| // If auto-resolve is disabled, throw error on first conflict | |
| if (!autoResolveConflicts) { | |
| const { APIError } = await import("payload"); | |
| throw new APIError(`A document with this slug already exists: ${value}`, 400, [ | |
| { | |
| field: "slug", | |
| message: "This slug is already in use. Please choose a different one.", | |
| }, | |
| ]); | |
| } | |
| // Auto-resolve by appending counter | |
| counter++; | |
| finalSlug = `${value}-${counter}`; | |
| } | |
| } | |
| return value; | |
| }; | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment