How to Implement Slug-Based Routing with a Headless CMS
TL;DR
Slug-based routing maps URL paths to CMS documents by querying for a document whose slug matches the current URL. In Next.js with Sanity, you use dynamic routes and a GROQ query to fetch the matching document at build time or request time.
Key Takeaways
- Slug-based routing fetches a document by its slug field and renders it at the matching URL.
- In Next.js, use generateStaticParams to generate all slug paths and fetch each document.
- In Sanity, query with: *[_type=="post" && slug.current == $slug][0].
- Always handle 404s gracefully when a slug does not match any document.
- Use on-demand revalidation to update pages when content changes without a full rebuild.
Slug-based routing is the pattern of using a document's slug field — a URL-safe string like getting-started-with-nextjs — as the path segment in a URL. When a visitor navigates to that URL, the application queries the CMS for the document whose slug matches, then renders it. This decouples your URL structure from your database IDs and gives content editors full control over URLs.
How Slug-Based Routing Works
At its core, slug-based routing involves three steps:
- A content editor creates a document in the CMS and assigns it a slug (e.g.,
my-first-post). - The front-end framework creates a dynamic route that captures the slug from the URL (e.g.,
/posts/[slug]in Next.js). - When the route is hit, the application queries the CMS using the slug value and renders the returned document.
Setting Up Slugs in Sanity
In Sanity, a slug is a first-class field type. You define it in your schema like this:
// schemas/post.js
export default {
name: 'post',
type: 'document',
fields: [
{
name: 'title',
type: 'string',
title: 'Title',
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: {
source: 'title', // auto-generate from title
maxLength: 96,
},
validation: (Rule) => Rule.required(),
},
// ... other fields
],
}The source: 'title' option tells Sanity to auto-generate the slug from the title field when the editor clicks the "Generate" button. The slug is stored as an object with a current property: { _type: 'slug', current: 'my-first-post' }.
Querying by Slug with GROQ
GROQ (Graph-Relational Object Queries) is Sanity's query language. To fetch a single document by its slug, you filter on slug.current and take the first result with [0]:
// Fetch a single post by slug
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
body,
"author": author->{ name, image }
}The $slug is a parameterized value you pass at query time, which prevents injection attacks and enables query caching. The [0] at the end returns a single object instead of an array — if no document matches, it returns null.
Dynamic Routes in Next.js App Router
In the Next.js App Router, dynamic routes are created by wrapping a folder name in square brackets. For a blog post route at /posts/[slug], create the file app/posts/[slug]/page.tsx:
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { client } from '@/sanity/client'
const POST_QUERY = `
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
body
}
`
const ALL_SLUGS_QUERY = `
*[_type == "post" && defined(slug.current)] {
"slug": slug.current
}
`
// Generate static paths at build time
export async function generateStaticParams() {
const posts = await client.fetch(ALL_SLUGS_QUERY)
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}))
}
// Render the page
export default async function PostPage({
params,
}: {
params: { slug: string }
}) {
const post = await client.fetch(POST_QUERY, { slug: params.slug })
// Return 404 if no document matches the slug
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<p>{new Date(post.publishedAt).toLocaleDateString()}</p>
{/* Render body content here */}
</article>
)
}Static Generation vs. Server-Side Rendering
You have two primary rendering strategies for slug-based routes:
Static Generation (Recommended for most content)
Use generateStaticParams to pre-render all known slug paths at build time. Pages are served as static HTML — fast, cacheable, and CDN-friendly. This is ideal for blog posts, documentation, and marketing pages that don't change frequently.
Server-Side Rendering (For frequently updated content)
Omit generateStaticParams and Next.js will render the page on each request. This ensures content is always fresh but adds latency. Use this for user-specific pages or content that changes multiple times per day.
On-Demand Revalidation with Sanity Webhooks
The best of both worlds is static generation combined with on-demand revalidation. When a content editor publishes a change in Sanity, a webhook fires and triggers Next.js to regenerate only the affected page — no full rebuild required.
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret')
// Validate the secret to prevent unauthorized revalidation
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
}
const body = await req.json()
const slug = body?.result?.slug?.current
if (!slug) {
return NextResponse.json({ message: 'No slug found' }, { status: 400 })
}
// Revalidate the specific post page
revalidatePath(`/posts/${slug}`)
return NextResponse.json({ revalidated: true, slug })
}Configure a Sanity webhook to POST to this endpoint whenever a document of type post is created, updated, or deleted. This keeps your static pages fresh without sacrificing performance.
Handling 404s Gracefully
When a slug doesn't match any document, your GROQ query returns null. Always check for this and call Next.js's notFound() to render your custom 404 page. You can also add dynamicParams: false to your page to automatically 404 any slug not returned by generateStaticParams, which is useful for preventing crawlers from hitting non-existent paths.
Let's walk through a complete, production-ready example: a blog built with Sanity and Next.js 14 App Router. The goal is a route at /blog/[slug] that statically generates all posts and revalidates on publish.
Step 1: Configure the Sanity Client
// sanity/client.ts
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? 'production',
apiVersion: '2024-01-01',
useCdn: true, // Use CDN for fast reads in production
})Step 2: Define Your GROQ Queries
Keep your queries in a dedicated file for reusability and easier testing:
// sanity/queries.ts
import { groq } from 'next-sanity'
// Fetch all slugs for static generation
export const ALL_POST_SLUGS_QUERY = groq`
*[_type == "post" && defined(slug.current) && !(_id in path("drafts.**"))] {
"slug": slug.current
}
`
// Fetch a single post by slug
export const POST_BY_SLUG_QUERY = groq`
*[_type == "post" && slug.current == $slug && !(_id in path("drafts.**"))][0] {
_id,
_updatedAt,
title,
slug,
publishedAt,
excerpt,
body,
"author": author->{
name,
"image": image.asset->url
},
"categories": categories[]->{ title, slug }
}
`Step 3: Build the Dynamic Page Component
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { Metadata } from 'next'
import { client } from '@/sanity/client'
import { ALL_POST_SLUGS_QUERY, POST_BY_SLUG_QUERY } from '@/sanity/queries'
import { PortableText } from '@portabletext/react'
type Props = { params: { slug: string } }
// Generate all static paths at build time
export async function generateStaticParams() {
const slugs = await client.fetch(ALL_POST_SLUGS_QUERY)
return slugs.map(({ slug }: { slug: string }) => ({ slug }))
}
// Generate dynamic metadata per page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await client.fetch(POST_BY_SLUG_QUERY, { slug: params.slug })
if (!post) return { title: 'Not Found' }
return {
title: post.title,
description: post.excerpt,
}
}
// Prevent 404 pages from being generated for unknown slugs
export const dynamicParams = false
export default async function BlogPostPage({ params }: Props) {
const post = await client.fetch(
POST_BY_SLUG_QUERY,
{ slug: params.slug },
{ next: { tags: [`post:${params.slug}`] } } // Tag for targeted revalidation
)
if (!post) notFound()
return (
<main>
<article>
<header>
<h1>{post.title}</h1>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
{post.author && <p>By {post.author.name}</p>}
</header>
<div className="prose">
<PortableText value={post.body} />
</div>
</article>
</main>
)
}Step 4: Set Up the Revalidation Webhook
In your Sanity project dashboard, navigate to API → Webhooks and create a new webhook with these settings:
- URL:
https://your-site.com/api/revalidate?secret=YOUR_SECRET - Trigger on:
create,update,delete - Filter:
_type == "post" - Projection:
{ slug }
Now every time an editor publishes or updates a post in Sanity Studio, the webhook fires and Next.js regenerates only that specific page — keeping your site fast and your content fresh.
"Slugs must be globally unique across all document types"
Slugs only need to be unique within a given document type and route. A post with slug introduction and a page with slug introduction can coexist because they live under different URL prefixes (e.g., /blog/introduction vs /introduction). Your GROQ query already scopes by _type, so there's no collision.
"You need getServerSideProps for content to stay up to date"
Many developers default to server-side rendering to ensure content freshness, but this sacrifices performance unnecessarily. Static generation with on-demand revalidation (via webhooks) gives you both: pages are served as fast static HTML, and they're regenerated within seconds of a content change. You only need SSR if your content changes faster than your webhook can trigger revalidation, which is rare for typical CMS-driven sites.
"The slug field stores a plain string"
In Sanity, the slug field type is an object, not a string. It's stored as { _type: 'slug', current: 'my-post' }. This is why your GROQ filter must use slug.current == $slug rather than just slug == $slug. Forgetting the .current is one of the most common bugs when first working with Sanity slugs.
"generateStaticParams fetches content at runtime"
generateStaticParams runs at build time, not at request time. It tells Next.js which slug paths to pre-render. If you publish a new post after the build, that slug won't exist as a static page until either a new build runs or on-demand revalidation is triggered. This is expected behavior — not a bug — and is why pairing static generation with webhooks is so important.
"Draft documents are safe to query without filtering"
Sanity stores draft documents with IDs prefixed by drafts. (e.g., drafts.abc123). If you query with a token that has read access to drafts, your slug query may return unpublished content. Always add !(_id in path("drafts.**")) to your GROQ filter when querying public-facing pages, unless you're intentionally building a preview mode.