Skip to main content

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.

Portrait of Henrik Larsson
By Henrik,  

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:

  1. It should have a default configurable root item.
  2. The ability to receive an array of items and render them.
  3. Capability to accept additional items and add them to the beginning of the array.
  4. Visually separate items
  5. Ensure accessibilty
    1. Wrapped in a <nav> element with an aria-label.
    2. Contains an ordered list (<ol>) inside the <nav> element.
    3. The ordered list should have list items (<li>) with links (<a>) inside them.
    4. 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.

Let's start by setting up the breadcrumb component we'll use throughout this guide.

/components/shared/Breadcrumbs.tsx (typescript)Copy
// 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 .

/components/shared/Breadcrumbs.tsx (typescript)Copy
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:

  1. Add a default root link to the homepage with a configurable label.
  2. Add a separator after the home item
  3. Add support for "prefixItems" to pass in items at the start of the breadcrumb (after the default home link).
  4. Map through our items
  5. Ensure an item is not null/undefined
  6. Check if the item is the last item
  7. Resolve item's href/url
  8. Return if the href can't be resolved.
  9. Render an item. If it's the last, apply different styles and don't add a separator after.
/components/shared/Breadcrumbs.tsx (typescript)Copy
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:

app/(personal)/projects/page.tsx (typescript)Copy
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:

/types/index.ts (typescript)Copy
// 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:

/sanity/lib/queries.ts (typescript)Copy
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.

/components/pages/project/ProjectPage.tsx (typescript)Copy
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:

  1. Add a reference field to our 'page' document to relate it to other 'page' documents.
  2. If a page has a parent, base the current page's slug on the parent's slug when generating a slug.
  3. Provide a breadcrumb by querying a page and check if it has a parent or even a grandparent.
  4. 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:

/sanity/schemas/documents/page.tsx (typescript)Copy
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:

/sanity/schemas/documents/page.tsx (typescript)Copy
// 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: &nbsp;
       <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'.

/sanity/schemas/documents/page.tsx (typescript)Copy
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.

/sanity/lib/queries.ts (typescript)Copy
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:

/types/index.ts (typescript)Copy
export interface PagePayload {
  body?: PortableTextBlock[]
  name?: string
  overview?: PortableTextBlock[]
  title?: string
  slug?: string | string[]
  // Add this
  breadcrumb?: {
    items: MenuItem[]
  }
}

☝️ Source code

/components/pages/page/Page.tsx (typescript)Copy
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