Build a breadcrumb using Sanity and Next.js
Crafting breadcrumbs poses its challenges, but by combining the capabilities of Sanity.io and Next.js, those challenges are easier to approach.
What we'll build
Our goal is to create a versatile, dynamic, and accessible breadcrumb. I've listed some key features and requirements for the breadcrumb component:
- It should have a default configurable root item.
- The ability to receive an array of items and render them.
- Capability to accept additional items and add them to the beginning of the array.
- Visually separate items
- Ensure accessibilty
- Wrapped in a <nav> element with an aria-label.
- Contains an ordered list (<ol>) inside the <nav> element.
- The ordered list should have list items (<li>) with links (<a>) inside them.
- Indicate the current page/route for the user using aria-current.
Getting started
Note: This guide assumes you have an existing Sanity instance connected to a Next.js frontend.
If not, I've prepared a demo site and a GitHub repo you can check out and use:
Demo: https://sanity-next-breadcrumbs.vercel.app/
Source code: https://github.com/Henkisch/sanity-next-breadcrumbs
I've used Sanity's template-nextjs-personal-website repository as a starting point, and you could do the same.
Breadcrumb component
Let's start by setting up the breadcrumb component we'll use throughout this guide.
// 1. Import Next.js link component for later usage.
import Link from 'next/link'
// 2. Import utility function for resolving url depending on type.
import { resolveHref } from '@/sanity/lib/utils'
// 3. Import type MenuItem to set a format for breadcrumb items.
import type { MenuItem } from '@/types'
// 4. Create an interface so our component knows what it should expect to get.
interface BreadCrumbsProps {
items: MenuItem[];
}
// 5. Export a Breadcrumbs component with a prop: items, defined above.
export default function Breadcrumbs({ items }: BreadCrumbsProps) {
// 6. Return early if no items are provided.
if (!items) return
// 7. Add a nav-element with aria-label and basic styles.
return (
<nav aria-label="Breadcrumb navigation" className={`px-4 md:px-16 lg:px-32 py-3 bg-gray-50/60`}>
Breadcrumbs
</nav>
)
}
Define a separator icon
To visually separate the items in our breadcrumb, let's prepare a separator, the icon used is the "Chevron right" icon from Radix Icons.
import Link from 'next/link'
import { resolveHref } from '@/sanity/lib/utils'
import type { MenuItem } from '@/types'
interface BreadCrumbsProps {
items: MenuItem[];
}
export default function Breadcrumbs({ items }: BreadCrumbsProps) {
if (!items) return
// Define the separator.
const Separator = () => (
// aria-hidden makes sure the item will not be interpreted by
// screen readers.
<span aria-hidden="true" className="text-gray-600">
<svg
className="w-4 h-4"
aria-hidden="true"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6.18194 4.18185C6.35767 4.00611 6.6426 4.00611 6.81833 4.18185L9.81833 7.18185C9.90272 7.26624 9.95013 7.3807 9.95013 7.50005C9.95013 7.6194 9.90272 7.73386 9.81833 7.81825L6.81833 10.8182C6.6426 10.994 6.35767 10.994 6.18194 10.8182C6.0062 10.6425 6.0062 10.3576 6.18194 10.1819L8.86374 7.50005L6.18194 4.81825C6.0062 4.64251 6.0062 4.35759 6.18194 4.18185Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path>
</svg>
</span>
)
return (
<nav aria-label="Breadcrumb navigation" className={`px-4 md:px-16 lg:px-32 py-3 bg-gray-50/60`}>
Breadcrumbs
</nav>
)
}
Add breadcrumb items
Now let's populate our breadcrumb. In this step we will:
- Add a default root link to the homepage with a configurable label.
- Add a separator after the home item
- Add support for "prefixItems" to pass in items at the start of the breadcrumb (after the default home link).
- Map through our items
- Ensure an item is not null/undefined
- Check if the item is the last item
- Resolve item's href/url
- Return if the href can't be resolved.
- Render an item. If it's the last, apply different styles and don't add a separator after.
import Link from 'next/link'
import { resolveHref } from '@/sanity/lib/utils'
import type { MenuItem } from '@/types'
interface BreadCrumbsProps {
items: MenuItem[];
rootLabel?: string;
prefixItems?: MenuItem[];
}
export default function Breadcrumbs({ items, rootLabel = "Home", prefixItems = [] }: BreadCrumbsProps) {
if (!items) return
let allItems: MenuItem[]
if (prefixItems.length > 0) {
allItems = [...prefixItems, ...items]
items.concat(prefixItems)
} else {
allItems = items;
}
const Separator = () => (
<span aria-hidden="true" className="text-gray-600">
<svg
className="w-4 h-4"
aria-hidden="true"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6.18194 4.18185C6.35767 4.00611 6.6426 4.00611 6.81833 4.18185L9.81833 7.18185C9.90272 7.26624 9.95013 7.3807 9.95013 7.50005C9.95013 7.6194 9.90272 7.73386 9.81833 7.81825L6.81833 10.8182C6.6426 10.994 6.35767 10.994 6.18194 10.8182C6.0062 10.6425 6.0062 10.3576 6.18194 10.1819L8.86374 7.50005L6.18194 4.81825C6.0062 4.64251 6.0062 4.35759 6.18194 4.18185Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path>
</svg>
</span>
)
return (
<nav aria-label="Breadcrumb navigation" className={`px-4 md:px-16 lg:px-32 py-3 bg-gray-50/60`}>
<ol className={'flex items-center gap-1 text-sm list-none m-0'}>
<li className={'text-gray-600 transition hover:text-gray-900'}><Link href={'/'}>{rootLabel}</Link></li>
<Separator />
{allItems.map((link, index: number) => {
if(!link) return;
const isLast = allItems.length === index + 1;
const href = resolveHref(link?._type, link?.slug)
if (!href) {
return null
}
return (
<>
<li key={link.slug}>
<Link
className={`transition ${isLast && 'text-gray-900 transition hover:text-gray-600'} ${!isLast && 'text-gray-600 hover:text-gray-900'}`}
href={href}
aria-current={isLast ? 'page' : 'false'}
>
{link.title}
</Link>
</li>
{!isLast && (
<Separator />
)}
</>
)
})}
</ol>
</nav>
)
}
☝️ Source code
Test the breadcrumb
Let's test our breadcrumb. Initially, we'll do it in a "static" context without involving Sanity and GROQ .
We'll use the /projects route to list our existing projects and provide a static breadcrumb for that page:
import Link from 'next/link'
import BreadCrumbs from '@/components/shared/Breadcrumbs'
import { Header } from '@/components/shared/Header'
export function ProjectsPage() {
// Here we feed our breadcrumb with only one predefined item.
// We make sure it follows the expected format with a slug, title and _type.
const items = [
{
"slug": "projects",
"title": "Projects",
"_type": "page"
}
]
return (
<div className="space-y-20">
<BreadCrumbs items={items} />
<div className="px-4 md:px-16 lg:px-32">
<Header title={'Projects'} subTitle={"Note: This page is hardcoded in this demo."} />
<ul className="flex flex-col mt-12 gap-4 ml-0 font-bold">
<li>
<Link href="/projects/project-x">
Projext X
</Link>
</li>
<li>
<Link href="/projects/project-y">
Projext Y
</Link>
</li>
<li>
<Link href="/projects/project-z">
Projext Z
</Link>
</li>
</ul>
</div>
</div>
)
}
export default ProjectsPage
☝️ Source code, Demo
Provide breadcrumbs with GROQ
We've added and tested a breadcrumb on the projects landing page. Now let's add it to our single project routes. There are a few files we need to modify to prepare the projects to receive our breadcrumb:
// Before
export interface ProjectPayload {
client?: string
coverImage?: Image
description?: PortableTextBlock[]
duration?: {
start?: string
end?: string
}
overview?: PortableTextBlock[]
site?: string
slug: string
tags?: string[]
title?: string
}
// After
export interface ProjectPayload {
client?: string
coverImage?: Image
description?: PortableTextBlock[]
duration?: {
start?: string
end?: string
}
overview?: PortableTextBlock[]
site?: string
slug: string
tags?: string[]
title?: string
// Add this
breadcrumb?: {
items: MenuItem[]
}
}
☝️ Source code
Next, we need to modify our projectsBySlugQuery where we will construct our breadcrumb using projections as defined earlier:
export const projectBySlugQuery = groq`
*[_type == "project" && slug.current == $slug][0] {
_id,
client,
coverImage,
description,
duration,
overview,
site,
"slug": slug.current,
tags,
title,
"breadcrumb": {
"items": [
// Provide a parent item for each project so we can navigate back.
{
"_type": "page",
"slug": "projects",
"title": "Projects"
},
// Add the current path.
{
_type,
title,
"slug": slug.current
},
],
},
}
`
☝️ Source code
We're all set to render our breadcrumb for single project routes. Let's add the breadcrumb component to that page and provide the data needed to output it.
import type { EncodeDataAttributeCallback } from '@sanity/react-loader/rsc'
import Link from 'next/link'
// Add this
import BreadCrumbs from '@/components/shared/Breadcrumbs'
import { CustomPortableText } from '@/components/shared/CustomPortableText'
import { Header } from '@/components/shared/Header'
import ImageBox from '@/components/shared/ImageBox'
import type { ProjectPayload } from '@/types'
export interface ProjectPageProps {
data: ProjectPayload | null
encodeDataAttribute?: EncodeDataAttributeCallback
}
export function ProjectPage({ data, encodeDataAttribute }: ProjectPageProps) {
// Default to an empty object to allow previews on non-existent documents
const {
client,
coverImage,
description,
duration,
overview,
site,
tags,
title,
// Add this
breadcrumb
} = data ?? {}
const startYear = new Date(duration?.start!).getFullYear()
const endYear = duration?.end ? new Date(duration?.end).getFullYear() : 'Now'
return (
<div>
<div className="">
// Add this
{breadcrumb && (
<BreadCrumbs items={breadcrumb.items} />
)}
<div className="my-20 px-4 md:px-16 lg:px-32 space-y-6">
<Header title={title} description={overview} />
<div className="rounded-md border">
<ImageBox
data-sanity={encodeDataAttribute?.('coverImage')}
image={coverImage}
alt=""
classesWrapper="relative aspect-[16/9]"
/>
<div className="divide-inherit grid grid-cols-1 divide-y lg:grid-cols-4 lg:divide-x lg:divide-y-0">
{!!(startYear && endYear) && (
<div className="p-3 lg:p-4">
<div className="text-xs md:text-sm">Duration</div>
<div className="text-md md:text-lg">
<span data-sanity={encodeDataAttribute?.('duration.start')}>
{startYear}
</span>
{' - '}
<span data-sanity={encodeDataAttribute?.('duration.end')}>
{endYear}
</span>
</div>
</div>
)}
{client && (
<div className="p-3 lg:p-4">
<div className="text-xs md:text-sm">Client</div>
<div className="text-md md:text-lg">{client}</div>
</div>
)}
{site && (
<div className="p-3 lg:p-4">
<div className="text-xs md:text-sm">Site</div>
{site && (
<Link
target="_blank"
className="text-md break-words md:text-lg"
href={site}
>
{site}
</Link>
)}
</div>
)}
{tags && (
<div className="p-3 lg:p-4">
<div className="text-xs md:text-sm">Tags</div>
<div className="text-md flex flex-row flex-wrap md:text-lg">
{tags?.map((tag, key) => (
<div key={key} className="mr-1 break-words ">
#{tag}
</div>
))}
</div>
</div>
)}
</div>
</div>
{description && (
<CustomPortableText
paragraphClasses="font-serif max-w-3xl text-xl text-gray-600"
value={description}
/>
)}
</div>
</div>
<div className="absolute left-0 w-screen border-t" />
</div>
)
}
export default ProjectPage
☝️ Source code, Demo
A more advanced example
We've got a working breadcrumb. Let's add a more advanced example that truly showcases the strength and power of Sanity, GROQ, and Next.js together. In this section, we will:
- Add a reference field to our 'page' document to relate it to other 'page' documents.
- If a page has a parent, base the current page's slug on the parent's slug when generating a slug.
- Provide a breadcrumb by querying a page and check if it has a parent or even a grandparent.
- Generate pages in a catch-all route to create the desired structure.
☝️ Source code
Add page reference field to page document
Add this field to 'page' document type:
defineField({
name: 'parent',
type: 'reference',
title: 'Parent page',
// Weak true so that pages don't depend on each other if one is deleted.
weak: true,
description: "If added, bases this page's slug on the selected parent when clicking 'generate' on slug field.",
to: [{ type: 'page' }],
}),
☝️ Source code
Generate a slug based on our reference field
To alter the way our slug is generated, we'll provide a custom override function via options -> slugify:
// Original
defineField({
type: 'slug',
name: 'slug',
title: 'Slug',
options: {
source: 'title',
},
validation: (rule) => rule.required(),
}),
// New
defineField({
name: 'slug',
type: 'slug',
title: 'Slug',
validation: (Rule) => Rule.required(),
description: (
<>
URL this page will be available on:
<code>/<b>slug</b></code>.
</>
),
options: {
// @ts-ignore
source: (doc, options) => ({ doc, options }),
slugify: asyncSlugifier,
},
}),
☝️ Source code
AsyncSlugifier function
This function checks whether the current page has a parent. If so, base the generation of the current slug on the parent; otherwise, return the current slug. For example, if the current page slug is 'child-page' and it has a reference to the page with the slug 'parent-page', the final slug will be 'parent-page/child-page'.
import { client } from '@/sanity/lib/client'
async function asyncSlugifier(input: any) {
const parentQuery = '*[_id == $id][0]'
const parentQueryParams = {
id: input.doc.parent?._ref || '',
}
const parent = await client.fetch(parentQuery, parentQueryParams)
const parentSlug = parent?.slug?.current ? `${parent.slug.current}/` : ''
const pageSlug = input.doc.title
.toLowerCase()
.replace(/\s+/g, '-')
.slice(0, 200)
return `${parentSlug}${pageSlug}`
}
☝️ Source code
Provide page breadcrumbs with GROQ
Just like before, we'll build our breadcrumb in our GROQ-query for pages. The query below will check if a page has a parent and even a grandparent. If they don't, null will be returned.
export const pagesBySlugQuery = groq`
*[_type == "page" && slug.current == $slug][0] {
_id,
body,
overview,
title,
shortTitle,
"slug": slug.current,
"breadcrumb": {
"items": [
select(defined(parent->parent->) => {
"_type": parent->parent->_type,
"title": parent->parent->title,
"slug": parent->parent->slug.current
}),
select(defined(parent) => {
"_type": parent->_type,
"title": parent->title,
"slug": parent->slug.current
}),
{
_type,
title,
"slug": slug.current
},
],
},
}
`
☝️ Source code
Use the breadcrumb on pages
In the Sanity starter that this tutorial is based on, the original page route is not a catch all route. It's named '[slug]/page.tsx' and not '[...slug]/page.tsx' as in this example. Read more about dynamic routes in the Next.js docs.
Just like we did with projects, we need to prepare what a page expects to receive:
export interface PagePayload {
body?: PortableTextBlock[]
name?: string
overview?: PortableTextBlock[]
title?: string
slug?: string | string[]
// Add this
breadcrumb?: {
items: MenuItem[]
}
}
☝️ Source code
import BreadCrumbs from '@/components/shared/Breadcrumbs'
import { CustomPortableText } from '@/components/shared/CustomPortableText'
import { Header } from '@/components/shared/Header'
import type { PagePayload } from '@/types'
export interface PageProps {
data: PagePayload | null
}
export function Page({ data }: PageProps) {
// Default to an empty object to allow previews on non-existent documents
const { body, overview, title, breadcrumb } = data ?? {}
return (
<div>
<div className="space-y-20 mb-20">
{breadcrumb && (
<BreadCrumbs items={breadcrumb.items} />
)}
<div className="px-4 md:px-16 lg:px-32">
{/* Header */}
<Header title={title} description={overview} />
{/* Body */}
{body && (
<CustomPortableText
paragraphClasses="font-serif max-w-3xl text-gray-600 text-xl"
value={body}
/>
)}
</div>
</div>
<div className="absolute left-0 w-screen border-t" />
</div>
)
}
export default Page
☝️ Source code
For the purpose of this demo I've created three pages in my Sanity instance:
Parent page, Child page and Grand child page
- Parent page,
- parent: none
- slug: 'parent-page'
- Child page
- parent: parent-page
- slug: 'parent-page/child-page'
- Grand child page
- parent: child-page
- slug: 'parent-page/child-page/grand-child-page'
Possible next steps
A few ways to optimise and iterate this solution.
Alternate breadcrumb title/label
To optimise/control your breadcrumbs a little more, a tip is to add an extra field to your document types on which you base your breadcrumb item's labels.
For example, post/article titles might be quite long. If you add a field called "Short title," that might be more convenient for your breadcrumb, kind of like a "title alias".
Add multilingual support
Since we're fetching data from Sanity, handling different languages in our breadcrumb won't add that much complexity breadcrumb wise. As long as your front end can handle multiple languages, so will our breadcrumb. Our GROQ queries would need to handle some additional logic though.
A typical hassle for a Swedish developer like me is to handle/replace characters like 'å', 'ä', and 'ö' if you generate breadcrumb titles/labels from the current route slug. Since we're fetching actual content, this won't be a problem.
Hope you enjoyed this guide!
/H