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.

·Matija Žiberna·
How To Implement Slugs for Content and SKUs for Products in Payload CMS (With Safe Uniqueness + Seeding)

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

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 from title 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.
  • 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

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

How To Implement Slugs for Content and SKUs for Products in Payload CMS (With Safe Uniqueness + Seeding) | Build with Matija