Skip to content

Instantly share code, notes, and snippets.

@notflip
Created October 4, 2025 03:46
Show Gist options
  • Select an option

  • Save notflip/9794477c2ff88de618a61683c06a06f2 to your computer and use it in GitHub Desktop.

Select an option

Save notflip/9794477c2ff88de618a61683c06a06f2 to your computer and use it in GitHub Desktop.
Auto slug field in PayloadCMS
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;
};
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];
};
"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>
);
};
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