Type something to search...
How to Create Custom Slugs in Sanity

How to Create Custom Slugs in Sanity

To create custom slugs in Sanity, add a field with type: 'slug' to your document and configure its options: source sets which field (or function) the slug is generated from, slugify replaces the default formatting function with your own, maxLength caps the length, and isUnique controls how uniqueness is checked. Editors then click Generate in the Studio to build the slug from the source, or type one by hand. The value is stored as an object with a current property, so in GROQ you filter with slug.current == $slug.

Slugs become your URLs, so they affect SEO, link stability and how readable your site's addresses are. The built-in slug type works well out of the box, but most real projects need something extra: slugs built from several fields, a date prefix, transliteration rules, or uniqueness scoped to a language. This article covers how the slug type works, each option in detail, practical custom slugify functions, validation, querying and the edge cases that catch people out.

How the Slug Type Works

A slug field looks like a text input with a Generate button next to it. The basic definition is short:

// schemaTypes/post.ts
import { defineField, defineType } from 'sanity'

export const post = defineType({
  name: 'post',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: { source: 'title' },
      validation: (rule) => rule.required(),
    }),
  ],
})

The stored value is an object, not a string:

{
  "slug": {
    "_type": "slug",
    "current": "how-to-create-custom-slugs-in-sanity"
  }
}

That current property is what you'll query and use in URLs. If you're new to schema files, how to create your first schema in Sanity walks through the basics, and the helpers used here are explained in using defineType and defineField.

What the Default Slugify Does

Without a custom slugify, Sanity formats the source with the speakingurl library. It lowercases the text, replaces spaces and punctuation with hyphens, transliterates many accented characters (so "Café" becomes "cafe") and truncates the result to maxLength, which defaults to 200 characters. For a lot of English-language sites, that's all you need.

Uniqueness Is Checked by Default

The default isUnique check flags a slug as a validation error if another document of the same type already uses it. Drafts and the published version of the same document don't count as duplicates of each other.

The Slug Options

OptionTypeWhat it does
sourceField name, path or functionWhere the Generate button reads its input from
maxLengthNumberMaximum length passed to the default slugify (default 200)
slugifyFunctionReplaces the default formatting entirely
isUniqueFunctionReplaces the default uniqueness check
disableArrayWarningBooleanSilences the warning when a slug is used inside an array

source as a Field Path

source can point to any field, including nested ones, using a dot path:

options: { source: 'seo.title' }

For a slug inside an object, the path is still relative to the document root, not the object.

source as a Function

When the slug depends on more than one field, pass a function. It receives the whole document and a context object that includes parent, the object containing the slug field:

defineField({
  name: 'slug',
  type: 'slug',
  options: {
    source: (doc) => {
      const date = typeof doc.publishedAt === 'string' ? doc.publishedAt.slice(0, 10) : ''
      return `${date} ${doc.title ?? ''}`.trim()
    },
  },
})

A post titled "Hello World" published on 4 November 2026 now generates 2026-11-04-hello-world. The function may also return a promise, which is useful if part of the slug comes from a referenced document. The context includes getClient, so you can fetch it:

options: {
  source: async (doc, { getClient }) => {
    const client = getClient({ apiVersion: '2026-11-01' })
    const ref = (doc.category as { _ref?: string } | undefined)?._ref
    const category = ref
      ? await client.fetch<string | null>(`*[_id == $id][0].title`, { id: ref })
      : null
    return [category, doc.title].filter(Boolean).join(' ')
  },
},

Writing a Custom slugify Function

slugify receives the source string and returns the final slug. Once you set it, the default formatting no longer runs, so your function is responsible for everything, including truncation.

A Simple Custom Formatter

// lib/slugify.ts
export function customSlugify(input: string): string {
  return input
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '') // strip accents
    .toLowerCase()
    .replace(/&/g, ' and ')
    .replace(/[^a-z0-9\s-]/g, '')   // drop everything else
    .trim()
    .replace(/[\s-]+/g, '-')         // collapse spaces and hyphens
    .slice(0, 80)
    .replace(/-+$/g, '')             // no trailing hyphen after slicing
}
defineField({
  name: 'slug',
  type: 'slug',
  options: {
    source: 'title',
    slugify: customSlugify,
  },
})

This version turns ampersands into the word "and", which the default doesn't do, and caps slugs at 80 characters without leaving a trailing hyphen.

Removing Stop Words

Shorter slugs are easier to share. You can drop common words before formatting:

const STOP_WORDS = new Set(['a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'how'])

export function shortSlugify(input: string): string {
  return customSlugify(input)
    .split('-')
    .filter((word) => !STOP_WORDS.has(word))
    .join('-')
}

"How to Create Custom Slugs in Sanity" becomes create-custom-slugs-sanity. Keep the list short, though: removing too many words makes slugs ambiguous.

Using a Library

If you need proper transliteration for many scripts, use a dedicated package such as slugify from npm rather than writing your own rules:

npm install slugify
import slugifyLib from 'slugify'

options: {
  source: 'title',
  slugify: (input) => slugifyLib(input, { lower: true, strict: true }).slice(0, 96),
},

Custom Uniqueness Rules

Sometimes the default uniqueness check is too strict or too loose. Common cases:

  • Same slug in different languages: /en/about and /fr/about should both be allowed.
  • Uniqueness across types: a page and a post that share a URL space shouldn't share a slug.
  • Uniqueness within a parent: a docs site where /guides/setup and /api/setup are both valid.

isUnique receives the slug and a context with the current document and getClient. Return true when the slug is acceptable. Here is a check that only flags duplicates in the same language:

// lib/isUniqueInLanguage.ts
import type { SlugValidationContext } from 'sanity'
import { getPublishedId } from 'sanity'

export async function isUniqueInLanguage(slug: string, context: SlugValidationContext) {
  const { document, getClient } = context
  if (!document) return true

  const client = getClient({ apiVersion: '2026-11-01' })
  const publishedId = getPublishedId(document._id)

  const query = `!defined(*[
    _type == $type &&
    slug.current == $slug &&
    language == $language &&
    !(_id in [$publishedId, "drafts." + $publishedId]) &&
    !(_id in path("versions.**"))
  ][0]._id)`

  return client.fetch<boolean>(query, {
    type: document._type,
    slug,
    language: document.language ?? null,
    publishedId,
  })
}
options: {
  source: 'title',
  isUnique: isUniqueInLanguage,
},

The query excludes the document's own published and draft versions so a document never conflicts with itself, and skips release versions (IDs under versions.), which aren't live yet. getPublishedId() strips any draft or version prefix from the current ID, so the check works whichever version the editor has open. To check across types, swap _type == $type for _type in ["page", "post"]. Notice the slug is passed as a GROQ parameter rather than concatenated into the query; using GROQ parameters safely explains why that matters.

The context also includes defaultIsUnique, so you can add a rule on top of the built-in check rather than replacing it.

Validating Slug Format

isUnique only covers duplicates. Editors can still type a slug by hand, so add validation for the format too:

defineField({
  name: 'slug',
  type: 'slug',
  options: { source: 'title', slugify: customSlugify },
  validation: (rule) =>
    rule.required().custom((value) => {
      const current = value?.current
      if (!current) return true // required() handles empty values
      if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(current)) {
        return 'Use lowercase letters, numbers and single hyphens only'
      }
      if (current.length > 80) return 'Keep slugs to 80 characters or fewer'
      return true
    }),
})

This catches uppercase letters, spaces, double hyphens and leading or trailing hyphens, whether the slug was generated or typed. The wider validation API is covered in how to add validation rules to Sanity fields.

Querying by Slug

Because the value is an object, filter on slug.current:

*[_type == "post" && slug.current == $slug][0]{
  _id,
  title,
  "slug": slug.current,
  publishedAt
}

In a Next.js App Router page:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { client } from '@/sanity/client'

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]{ title, "slug": slug.current }`

export async function generateStaticParams() {
  const slugs = await client.fetch<string[]>(
    `*[_type == "post" && defined(slug.current)].slug.current`,
  )
  return slugs.map((slug) => ({ slug }))
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await client.fetch(POST_QUERY, { slug })
  if (!post) notFound()

  return <h1>{post.title}</h1>
}

The defined(slug.current) filter in generateStaticParams skips documents with no slug, which otherwise produce broken routes. Projecting "slug": slug.current flattens the object into a plain string for your components.

Edge Cases and Gotchas

  • Changing a slug breaks links: once a page is published, editing its slug changes its URL. Consider making the field read-only after publishing, or keep a list of old slugs and redirect them.
  • Generate doesn't run automatically: the slug isn't created when the title changes. Editors must click Generate, which is deliberate so published URLs don't shift when a title is edited.
  • Slugs in arrays: Sanity warns when a slug is used inside an array, because uniqueness checks assume one slug per document. Use disableArrayWarning: true only if you really mean it.
  • Empty source: if the source is empty, Generate produces an empty slug. Pair the slug with required() on both fields.
  • Leading slashes: don't store /blog/my-post in a slug. Store my-post and build paths in your front end, so you can restructure routes without rewriting content.

A simple way to lock slugs is a conditional property that makes the field read-only once a publishedAt date has been set, while still letting administrators change it:

readOnly: ({ document, currentUser }) =>
  Boolean(document?.publishedAt) &&
  !currentUser?.roles.some((role) => role.name === 'administrator'),

More patterns like this are in conditional fields in Sanity.


FAQ: Custom Slugs in Sanity

The slug type stores an object with _type set to slug and the actual value in current. This lets Sanity attach behaviour to the type. In GROQ, filter with slug.current and project it with "slug": slug.current to get a plain string.

Yes. Set source to a function that receives the document and returns a string, for example combining a date and a title. The function can be async if it needs to fetch referenced data.

Provide a custom isUnique function that queries for other documents with the same slug and the same language, excluding the current document's draft and published IDs. Return true when no conflict is found.

Effectively, yes. maxLength is applied by the default slugify function. When you provide your own, you need to handle truncation yourself.

Slugs are generated only when an editor clicks Generate. This stops published URLs from changing unexpectedly when someone edits a title.

Yes. Editors can always type a slug manually, so a custom validation rule that enforces lowercase letters, numbers and hyphens keeps URLs consistent.


Conclusion

The slug type gives you a sensible default, and its options let you shape it to fit your URLs. Use source to decide what a slug is built from, slugify to control formatting, isUnique to define what counts as a duplicate, and a custom validation rule to keep hand-typed slugs in line.

Store only the slug segment, query it with slug.current and a parameter, and treat published slugs as permanent. With those habits, your URLs stay clean, predictable and stable as your content grows.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper