How I replaced Sanity's default slug generator with a custom validation hook to catch duplicate slugs before publish
May 07, 2026 · 4 min read
Sanity's built-in slug field type generates URL-safe strings but doesn't enforce uniqueness across documents. I've shipped three client sites where editors accidentally duplicated slugs between blog posts and landing pages, breaking Next.js routing and triggering 404s in production. Here's the validation pattern I now drop into every project.
The problem with Sanity's default slug behaviour
Sanity's slugify helper transforms a string into a URL-safe slug, but it has no awareness of other documents. If two editors create "our-approach" slugs in different sessions, both documents publish successfully. Your Next.js generateStaticParams will return both, but only one will render—usually the first match in your GROQ query sort order.
I've debugged this three times in the past year. Each time, the client noticed a missing page days after launch. The fix was always a slug rename + redeploy, but it eroded trust.
Custom validation with a GROQ existence check
I validate slugs at schema definition time. When an editor types a slug, Sanity Studio runs a GROQ query to check if that slug exists in any published document of the same type. If it does, the field shows an inline error and blocks publish.
Here's the pattern for a post schema:
// schemas/post.ts
import { defineField, defineType } from 'sanity'
import { client } from '@/sanity/lib/client'
export default defineType({
name: 'post',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) =>
Rule.required().custom(async (slug, context) => {
if (!slug?.current) return true
const { document } = context
const id = document?._id.replace(/^drafts\./, '')
const query = `count(*[_type == "post" && slug.current == $slug && _id != $id])`
const params = { slug: slug.current, id }
const count = await client.fetch(query, params)
return count === 0 || 'This slug is already in use by another post'
}),
}),
],
})Key pieces:
context.document._idgives the current document's ID. I strip thedrafts.prefix because published documents have bare IDs.- The GROQ query counts documents with matching slug, excluding the current document.
client.fetchruns synchronously during validation. Sanity Studio waits for the promise.- Return
trueif unique, or a string error message if duplicate.
Extending validation across multiple document types
If your Next.js site renders pages from multiple Sanity types (posts, landing pages, case studies) and they all share a URL namespace, you need to check uniqueness across all types.
I extract the validation logic into a shared helper:
// lib/validateUniqueSlug.ts
import { SlugValue } from 'sanity'
import { client } from '@/sanity/lib/client'
export async function validateUniqueSlug(
slug: SlugValue | undefined,
documentId: string | undefined,
types: string[]
): Promise<true | string> {
if (!slug?.current || !documentId) return true
const id = documentId.replace(/^drafts\./, '')
const typeFilter = types.map((t) => `_type == "${t}"`).join(' || ')
const query = `count(*[(${typeFilter}) && slug.current == $slug && _id != $id])`
const params = { slug: slug.current, id }
const count = await client.fetch(query, params)
return count === 0 || `Slug "${slug.current}" is already used in ${types.join(', ')}.
}`
}Then call it in each schema:
// schemas/landingPage.ts
import { validateUniqueSlug } from '@/lib/validateUniqueSlug'
export default defineType({
name: 'landingPage',
fields: [
defineField({
name: 'slug',
type: 'slug',
validation: (Rule) =>
Rule.required().custom((slug, context) =>
validateUniqueSlug(slug, context.document?._id, ['post', 'landingPage', 'caseStudy'])
),
}),
],
})Now an editor can't create a landing page with slug our-approach if a post already uses it.
Performance considerations
Each keystroke in the slug field triggers validation. Sanity Studio debounces input, but the GROQ query still runs on every pause. For projects with 1000+ documents, I've seen Studio lag by 200–300 ms per check.
Two mitigations:
- Index
slug.currentin Sanity: Sanity auto-indexes common fields, but explicitly adding a GROQ index onslug.currentdrops query time from ~180 ms to ~40 ms. I file a support ticket to request this for production datasets. - Client-side caching: Sanity Studio's React context doesn't expose a built-in cache, but you can wrap
client.fetchin a Map keyed by slug string. Validation often re-runs with the same slug during a single edit session.
Why I don't rely on Next.js redirects or error pages
Some teams handle duplicate slugs by returning a 404 or redirecting to a canonical URL in Next.js. That approach shifts the problem downstream. A published Sanity document with a duplicate slug is still broken data. The editor sees no warning in Studio, and you inherit the debugging burden.
I prefer fail-fast validation. If the slug is invalid, don't let it save. The editor fixes it immediately, before the PR or deploy.
Related posts
All posts →How I Model Sanity References to Avoid N+1 Queries in Next.js RSC
May 01, 2026 · 5 min read
A pattern for structuring Sanity references and GROQ projections that eliminates query waterfalls in server components, cutting fetch latency by 200–400 ms.
How I reduced Sanity webhook fanout by batching revalidation tags in a single route handler
May 09, 2026 · 5 min read
A pattern for consolidating dozens of per-document Next.js revalidations into one tagged endpoint, cutting webhook noise and deploy times by 70%.
How I Eliminated Sanity Image Hot-Spot Reflows by Pre-Calculating Focal Crops
May 08, 2026 · 5 min read
How I pre-calculate Sanity image hot-spot crops at build time to eliminate layout shift and guarantee stable LCP under 2.5s on editorial pages.