How To Implement Slugs for Content and SKUs for Products in Payload CMS (With Safe Uniqueness + Seeding)
Separate human-friendly slugs from inventory SKUs while keeping Payload hooks and seeding predictable.

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
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 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:
// File: templates/website/src/fields/slug/formatSlug.ts (official example)
// Source: https://github.com/payloadcms/payload/blob/main/templates/website/src/fields/slug/formatSlug.ts
import type { FieldHook } from 'payload'
const format = (val: string): string => val
.replace(/ /g, '-')
.replace(/[^\w-\/]+/g, '')
.toLowerCase()
export const formatSlug = (fallback: string): FieldHook => ({ data, operation, originalDoc, value }) => {
if (typeof value === 'string' && value?.trim()) return format(value)
if (operation === 'create') {
const fallbackData = (data && data[fallback]) || (originalDoc && originalDoc[fallback])
if (typeof fallbackData === 'string') return format(fallbackData)
}
if (operation === 'update' && originalDoc?.slug) return originalDoc.slug
return 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
.
// File: src/fields/slug.ts
import deepMerge from "@/utilities/deepMerge";
import { formatSlug } from "@/utilities/formatSlug";
import type { Field } from "payload";
export const slugField = (fallback = "title", overrides: Partial<Field> = {}): Field => {
return deepMerge<Field, Partial<Field>>(
{
name: "slug",
type: "text",
index: true,
admin: { position: "sidebar" },
hooks: { beforeValidate: [formatSlug(fallback)] },
label: { sl: "Pot", en: "Path" },
},
overrides,
);
};
Use it in content collections and make it unique when appropriate:
// File: src/collections/Pages/index.ts
import { slugField } from "@/fields/slug";
export const Pages = {
slug: "pages",
fields: [
{ name: "title", type: "text", required: true },
slugField("title", { unique: true }),
// ...rest
],
};
What this does
- Generates a lowercase, URL‑safe
slug
fromtitle
on create - Preserves the slug on update unless explicitly changed
- Enforces uniqueness at the DB level when
unique: true
is set
Bridge to next step: Slugs are perfect for content, but products need stable, canonical IDs. That’s what SKUs are for.
2) Add skuField for Products
Products should use a canonical, immutable identifier. We use skuField
and keep any URL concerns separate (e.g., in ProductPages).
// File: src/fields/sku.ts
import deepMerge from "@/utilities/deepMerge";
import type { Field, FieldHook } from "payload";
const normalize = (value: string) =>
String(value)
.trim()
.toUpperCase()
.replace(/[^A-Z0-9._-]/g, "-")
.replace(/-+/g, "-");
const formatSku: FieldHook = ({ value, data, operation, originalDoc }) => {
// SEED path: deterministic, fast, no DB calls
if (process.env.SEED === "true") {
const input = typeof value === "string" && value.trim() !== "" ? value : undefined;
const srcFromKey = typeof data?.key === "string" && data.key.trim() !== "" ? data.key : undefined;
const srcFromTitle = typeof data?.title === "string" && data.title.trim() !== "" ? data.title : undefined;
const source = input ?? srcFromKey ?? srcFromTitle ?? "SKU";
return normalize(source);
}
// Admin/UI behavior
if (typeof value === "string" && value.trim() !== "") return normalize(value);
if (operation === "create") {
const srcFromKey = typeof data?.key === "string" && data.key.trim() !== "" ? data.key : undefined;
const srcFromTitle = typeof data?.title === "string" && data.title.trim() !== "" ? data.title : undefined;
const source = srcFromKey ?? srcFromTitle ?? "SKU";
return normalize(source);
}
if (operation === "update" && typeof originalDoc?.sku === "string") return originalDoc.sku;
return value as any;
};
export const skuField = (overrides: Partial<Field> = {}): Field =>
deepMerge<Field, Partial<Field>>(
{
name: "sku",
type: "text",
required: true,
unique: true,
index: true,
admin: {
position: "sidebar",
description: {
sl: "Edinstvena SKU koda (VELIKE ČRKE).",
en: "Unique SKU code (UPPERCASE).",
},
},
hooks: { beforeValidate: [formatSku] },
label: { sl: "SKU", en: "SKU" },
},
overrides,
);
Use it in the Products collection, alongside an optional stable key
used for seeds/integrations:
// File: src/collections/Products.ts
import { skuField } from "@/fields/sku";
import { slugField } from "@/fields/slug"; // for unrelated presentational pages
export const Products = {
slug: "products",
fields: [
slugField("title", { index: true }), // fine to keep for internal linking/UI, not identity
{ name: "key", type: "text", required: true, unique: true, index: true },
{ name: "title", type: "text", required: true, localized: true },
skuField({ label: { sl: "SKU koda", en: "SKU code" } }),
// ...rest
],
};
What this does
- 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.
3) Optional: Variant Slugs
Variants typically use variantSku
for identity. If you need URL paths, generate a variantSlug
that’s deterministic and SEED‑safe.
// File: src/collections/ProductVariants.ts (excerpt)
const normalize = (s: string) => s
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]/g, '-')
.replace(/-+/g, '-');
const buildVariantSlug = async ({ data, operation, req, originalDoc }: any) => {
if (process.env.SEED === 'true') {
const base = typeof data?.variantSku === 'string' && data.variantSku ? data.variantSku : 'variant';
return normalize(base);
}
if (operation !== 'create' && operation !== 'update') return originalDoc?.variantSlug;
const productId = (typeof data.product === 'object' ? data.product?.id : data.product) as number | string | undefined;
let productSlug = 'product';
let productSku = '';
try {
if (productId) {
const product = await req.payload.findByID({ collection: 'products', id: productId });
productSku = String(product.sku ?? '');
productSlug = normalize(String(product.title ?? productSku ?? 'product'));
}
} catch {}
const values = Array.isArray(data?.variantValues) ? data.variantValues : [];
const variantPart = values.map((v: any) => normalize(String(v?.value ?? ''))).filter(Boolean).join('-');
const combined = [productSlug, variantPart].filter(Boolean).join('-');
return combined || productSlug;
};
export const ProductVariants = {
slug: 'product-variants',
fields: [
{ name: 'product', type: 'relationship', relationTo: 'products', required: true },
{ name: 'variantSku', type: 'text', required: true, unique: true },
{ name: 'variantValues', type: 'array', fields: [ /* ... */ ] },
{ name: 'variantSlug', type: 'text', unique: true, index: true },
],
hooks: {
beforeValidate: [
async (args) => {
const slug = await buildVariantSlug(args);
if (!args.data) args.data = {};
args.data.variantSlug = slug;
return args.data;
},
],
},
};
What this does
- Generates a stable variant slug without DB calls under SEED
- Uses DB uniqueness for collision detection rather than heavy hook logic
Bridge to next step: Uniqueness and collisions behave differently during seeding vs Admin usage.
4) Uniqueness and Collision Handling
During seeding (SEED=true)
- Field hooks must be deterministic and fast. Don’t perform
req.payload.find
in field hooks. - Let DB unique constraints detect collisions. If a collision slips in, the seeder logs the error and continues/fails per your seed strategy.
- Prefer an explicit
key
in seed inputs to derive SKU deterministically. When you need a turnkey importer to feed those keys, pair this pattern with the workflow in How to Seed Payload CMS with CSV Files: A Complete Guide.
In the Admin UI
- 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.
- Skip the behavior when
- 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.
Thanks, Matija
Comments
You might be interested in

16th October 2025

12th October 2025

9th October 2025