Sanity Slug Validation: Block Duplicate Slugs Before Publish
By Nayan Kyada · · 6 min read
Part of The Sanity + Next.js Guide
Sanity's built-in slug field type generates URL-safe strings and, by default, checks uniqueness only among documents of the same type. That leaves a common gap: a blog post and a landing page can share a slug, which breaks Next.js routing when both live in the same URL namespace. Here's the validation pattern I drop into every project.
The problem with Sanity's default slug behaviour
Sanity's default uniqueness check is scoped to the document type and field path. If one editor creates a post with the slug "our-approach" and another creates a landing page with the same slug, both documents publish successfully. If both types render under the same route, your Next.js generateStaticParams will return the slug twice, but only one page will render—usually the first match in your GROQ query sort order.
This kind of bug is easy to miss: the page that loses usually isn't noticed until someone looks for it days after launch, and the fix is a slug rename plus redeploy.
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 on any other document. If it does, the field shows an inline error and blocks publish. Start with a single type to see the shape (this one duplicates the default check, but with your own message), then extend it across types in the next section.
Here's the pattern for a post schema:
// schemas/post.ts
import { defineField, defineType, getPublishedId } from 'sanity'
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, getClient } = context
if (!document?._id) return true
const client = getClient({ apiVersion: '2025-02-19' })
// Exclude every version of this document (published, draft, release versions)
const query = `count(*[_type == "post" && slug.current == $slug && !sanity::versionOf($published)])`
const params = { slug: slug.current, published: getPublishedId(document._id) }
const count = await client.fetch<number>(query, params)
return count === 0 || 'This slug is already in use by another post'
}),
}),
],
})Key pieces:
context.getClient()gives you a Studio-authenticated client — don't import your frontend's Next.js client into schema files.getPublishedId()strips thedrafts.(or release version) prefix, and!sanity::versionOf($published)excludes the draft, published and release versions of the current document — otherwise the document's own draft would count as a duplicate.client.fetchis asynchronous; Sanity Studio awaits the promise returned by the custom rule.- 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 { getPublishedId, type SlugValue, type ValidationContext } from 'sanity'
export async function validateUniqueSlug(
slug: SlugValue | undefined,
context: ValidationContext,
types: string[]
): Promise<true | string> {
const documentId = context.document?._id
if (!slug?.current || !documentId) return true
const client = context.getClient({ apiVersion: '2025-02-19' })
const query = `count(*[_type in $types && slug.current == $slug && !sanity::versionOf($published)])`
const params = { slug: slug.current, types, published: getPublishedId(documentId) }
const count = await client.fetch<number>(query, params)
return count === 0 || `Slug "${slug.current}" is already used in ${types.join(', ')}.`
}Then call it in each schema:
// schemas/landingPage.ts
import { defineField, defineType } from 'sanity'
import { validateUniqueSlug } from '@/lib/validateUniqueSlug'
export default defineType({
name: 'landingPage',
type: 'document',
fields: [
defineField({
name: 'slug',
type: 'slug',
validation: (Rule) =>
Rule.required().custom((slug, context) =>
validateUniqueSlug(slug, context, ['post', 'landingPage', 'caseStudy'])
),
}),
],
})Now an editor can't create a landing page with slug our-approach if a post already uses it.
Performance considerations
Validation re-runs as the document changes. Sanity Studio debounces input, but the GROQ query still runs on every pause, so on large datasets each check adds a network round-trip.
Two mitigations:
- Keep the query tight: filter on
_typefirst and usecount()(ordefined(...[0]._id)) so the query returns a number rather than documents. - 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.
Handling slug changes after publish
Uniqueness validation solves collisions at creation time, but the more expensive failure is an editor changing a slug on a page that's been live for six months — every external link and search result now 404s. I handle this with a redirect document type that gets created as part of the same workflow:
// schemas/redirect.ts
export const redirect = defineType({
name: "redirect",
title: "Redirect",
type: "document",
fields: [
defineField({ name: "from", type: "string", validation: (r) => r.required() }),
defineField({ name: "to", type: "string", validation: (r) => r.required() }),
defineField({ name: "permanent", type: "boolean", initialValue: true }),
],
});Next.js reads these at build time (or via ISR on a slow tag) and serves them through the redirects() config or proxy.ts (called middleware before Next.js 16). The important part is the workflow: a document action on publish compares the incoming slug against the published version, and if it changed, prompts the editor to create the redirect with both values pre-filled. Editors never hand-type redirect paths, so the redirects are never wrong.
Reserved slugs and route collisions
The other class of slug bug the default generator can't see: slugs that collide with your application's own routes. A page slugged api, blog, or studio will shadow — or be shadowed by — a real route depending on your folder structure, and the failure is confusing for everyone.
I keep a deny-list in the validation rule alongside the uniqueness check:
const RESERVED = new Set([
"api", "blog", "studio", "projects", "services",
"faq", "sitemap.xml", "robots.txt", "rss.xml",
]);
validation: (rule) =>
rule.custom((slug) =>
slug?.current && RESERVED.has(slug.current.split("/")[0])
? "This slug is reserved by the application"
: true
),It's ten lines, and it converts a subtle routing bug that would surface in production into an inline red error at typing time — which is the entire philosophy of doing this work in the Studio instead of in the framework.
Frequently asked questions
01Does Sanity's slug field enforce uniqueness automatically?
Only partly. By default the slug input checks uniqueness among documents of the same type at the same field path — so two posts can't share a slug, but a post and a landing page can. If those types share a URL namespace in Next.js, that collision breaks routing. Cross-type uniqueness has to be enforced with a custom `isUnique` function or validation rule, like the GROQ existence check below.
02How do I validate that a Sanity slug is unique across multiple document types?
Extract the check into a shared function that filters by an array of `_type` values instead of a single type, then call it from every schema that shares your URL namespace (posts, landing pages, case studies, etc.). See the 'Extending validation across multiple document types' section for the full pattern.
03How do I handle a Sanity slug that changes after the page has already been published?
Uniqueness validation only catches collisions at creation time — it won't stop an editor from renaming a live slug and breaking every inbound link. Pair it with a `redirect` document type created automatically on publish whenever the slug changes, so old URLs 301 to the new one instead of 404ing.