---
title: "How To Implement Slugs for Content and SKUs for Products in Payload CMS (With Safe Uniqueness + Seeding)"
slug: "payload-cms-slugs-and-skus"
published: "2025-10-11"
updated: "2025-12-25"
validated: "2025-10-20"
categories:
  - "Payload"
tags:
  - "Payload CMS"
  - "slugField"
  - "skuField"
  - "unique slugs"
  - "product SKU"
  - "seeding"
  - "field hooks"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "typescript"
  - "next.js"
status: "stable"
llm-purpose: "Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to TypeScript"
  - "Access to Next.js"
llm-outputs:
  - "Completed outcome: Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling"
---

**Summary Triples**
- (slugs, are generated, via a Payload field hook that formats input (title fallback) and enforces uniqueness)
- (SKUs, are distinct from slugs, and implemented as a separate skuField for product collections to avoid URL/ID collisions)
- (uniqueness during seeding, uses deterministic collision resolution, so seeds are reproducible and don't require admin UI interaction)
- (admin UI behavior, uses non-deterministic suffixing or interactive conflict handling, to avoid blocking users and to surface collisions immediately)
- (hook recursion/performance, is prevented, by designing field hooks to skip uniqueness checks when not required and by using efficient DB queries)
- (variant slugs, are optional, and can be generated per-variant while still checking global uniqueness across relevant collections)
- (collision handling helper, supports two modes, seed-safe deterministic mode and admin-safe interactive mode)
- (colocation, is recommended, to keep related fields/helpers and collection logic grouped for maintainability)

### {GOAL}
Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling

### {PREREQS}
- Access to Payload CMS
- Access to TypeScript
- Access to Next.js

### {STEPS}
1. Define slug versus SKU responsibilities
2. Implement the reusable slugField helper
3. Add a dedicated skuField for products
4. Optional: Compose variant slugs
5. Handle uniqueness and seeding collisions

<!-- llm:goal="Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:output="Completed outcome: Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling" -->

# How To Implement Slugs for Content and SKUs for Products in Payload CMS (With Safe Uniqueness + Seeding)
> Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling
Matija Žiberna · 2025-10-11

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](https://www.buildwithmatija.com/blog/how-to-build-ecommerce-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](https://www.buildwithmatija.com/blog/payload-cms-collection-structure-best-practices).

## 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.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`.

```typescript
// 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:

```typescript
// 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).

```typescript
// 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:

```typescript
// 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. For a complete implementation of variant systems with size/color options, check out [Shopify Variant System in Payload CMS](https://www.buildwithmatija.com/blog/shopify-variant-system-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.

```typescript
// 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](https://www.buildwithmatija.com/blog/how-to-seed-payload-cms-with-csv-files).

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

## LLM Response Snippet
```json
{
  "goal": "Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling",
  "responses": [
    {
      "question": "What does the article \"How To Implement Slugs for Content and SKUs for Products in Payload CMS (With Safe Uniqueness + Seeding)\" cover?",
      "answer": "Guide to implementing slugs and SKUs in Payload CMS with reusable helpers, uniqueness guarantees, seed-safe hooks, and collision handling"
    }
  ]
}
```