I was wiring up product/catalog data in Payload CMS when I hit a nasty class of issues: slug and SKU generation that was either too fragile during seeding or caused hook recursion and performance problems in the Admin UI. After a few iterations, I landed on a clean pattern that separates content slugs from product SKUs, keeps seeding deterministic, and enforces uniqueness without deadlocks. This guide is part of my complete series on How to Build E-commerce with Payload CMS.
This guide shows you the exact implementation I use in a Next.js + Payload project: slugs for content, SKUs for products, optional variant slugs, and collision handling that behaves differently (and safely) for seeding vs the Admin UI. If your collections are starting to sprawl while you add these helpers, reorganize them with the colocation pattern from How to Structure Payload CMS Collections for Long-Term Maintainability.
Quick Note: “Are slugs broken in Payload 3.0?”
I’ve seen confusion around Payload CMS 3.0 (including the Website template): users create pages or posts, set a title, and notice the slug stays null. That’s expected — Payload doesn’t auto‑generate slugs by default. You need to wire it up explicitly with a field hook.
There’s an official example in the template that shows how to do it manually via a field hook:
typescript
// File: templates/website/src/fields/slug/formatSlug.ts (official example)// Source: https://github.com/payloadcms/payload/blob/main/templates/website/src/fields/slug/formatSlug.tsimporttype { FieldHook } from'payload'const format = (val: string): string => val
.replace(/ /g, '-')
.replace(/[^\w-\/]+/g, '')
.toLowerCase()
exportconst formatSlug = (fallback: string): FieldHook =>({ data, operation, originalDoc, value }) => {
if (typeof value === 'string' && value?.trim()) returnformat(value)
if (operation === 'create') {
const fallbackData = (data && data[fallback]) || (originalDoc && originalDoc[fallback])
if (typeof fallbackData === 'string') returnformat(fallbackData)
}
if (operation === 'update' && originalDoc?.slug) return originalDoc.slugreturn value
}
In this article, we build on that exact approach: we use a slugField helper that delegates to a formatSlug hook for content, and a separate skuField for product identity. This separation solves the “slug stays null” confusion while keeping product identifiers stable and deterministic.
What We’re Building
slugField for human‑readable content URLs (pages, posts, etc.)
skuField for canonical product identity (required + unique)
Optional variant slugs derived from product + variant values
SEED‑safe generation (no DB calls in hooks) and clean Admin UI behavior
1) Add slugField for Content Collections
Slugs are for URLs. For non‑product content (pages, posts, etc.), we generate a slug from a fallback field such as title.
Guarantees a sku value exists on create (derived if absent) and stays normalized
Preserves SKU during updates unless explicitly changed
Uses a plain unique DB constraint to enforce uniqueness without hook‑time DB calls
Bridge to next step: If your variants need URLs, you can derive an optional variantSlug that combines the product identity with the variant values. For a complete implementation of variant systems with size/color options, check out Shopify Variant System in Payload CMS.
3) Optional: Variant Slugs
Variants typically use variantSku for identity. If you need URL paths, generate a variantSlug that’s deterministic and SEED‑safe.
Field hooks should stay pure (return only the field value). Normalize but avoid queries.
If you want auto‑suffixing (e.g., slug-2, slug-3), do it in a collection‑level hook with a guard:
Skip the behavior when SEED=true.
For Admin only, try a few suffixes with req.payload.find until you hit a free one; otherwise report a validation error.
In most cases, plain unique constraints + a clear error is enough and simpler to maintain.
Conclusion
We separated concerns cleanly: slugs for content URLs, SKUs for product identity, and optional variant slugs for URLs when needed. The field hooks return only field values, avoid DB calls, and handle seeding deterministically with SEED=true. Uniqueness is enforced at the database level, with optional Admin‑only suffixing handled in collection hooks when desired.
By following this pattern, you avoid the common recursion and performance pitfalls while keeping your identifiers stable and your URLs clean.
Let me know in the comments if you have questions, and subscribe for more practical development guides.