
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
| Option | Type | What it does |
|---|---|---|
source | Field name, path or function | Where the Generate button reads its input from |
maxLength | Number | Maximum length passed to the default slugify (default 200) |
slugify | Function | Replaces the default formatting entirely |
isUnique | Function | Replaces the default uniqueness check |
disableArrayWarning | Boolean | Silences 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/aboutand/fr/aboutshould both be allowed. - Uniqueness across types: a
pageand apostthat share a URL space shouldn't share a slug. - Uniqueness within a parent: a docs site where
/guides/setupand/api/setupare 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: trueonly 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-postin a slug. Storemy-postand 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.


