Building Dynamic Breadcrumbs in Next.js App Router

Originally Published on 2024-04-29

Updated on 2024-05-24

I've been wanting to figure out how to access the route parameters in a Next.js App Router project for a while and I recently came by a tweet from @fredkisss pointing to a Pull Request that had just landed in Next.js.The Pull Request he linked is support breadcrumb style catch-all parallel routes by ztanner, a Vercel employee and Next.js maintainer.

The Pull Request added functionality and demonstrates how to use the Parallel Routes feature to access the route parameters. I would encourage you to read through that documentation to get a better understanding of what Parallel Routes are but I will also summarize concepts as I write this out.

Parallel Route Slots#

To start on building our breadcrumbs we have to understand what slots are in Parallel Routes. slots are defined as

Parallel routes are created using named slots. Slots are defined with the @folder convention. ... Slots are passed as props to the shared parent layout.

What this means is that we can add a folder to our project named app/@breadcrumbs to create a slot. To be able to build out our breadcrumbs, we'll want to use a catch-all segment such as app/@breadcrumbs/[...catchAll]. Finally, we'll add a page which will render the breadcrumbs app/@breadcrumbs/[...catchAll]/page.tsx. For now, let's add a little placeholder until we're ready to build our breadcrumbs.

type Props = {
params: {
catchAll: string[]
}
}
export default function BreadcrumbsSlot({params: { catchAll } }: Props) {
console.log("rendering in @breadcrumbs", catchAll)
return <div>placeholder</div>
}
type Props = {
params: {
catchAll: string[]
}
}
export default function BreadcrumbsSlot({params: { catchAll } }: Props) {
console.log("rendering in @breadcrumbs", catchAll)
return <div>placeholder</div>
}

The logging statement will print out the array of route parameters that have been found.

default.tsx

A note when using Parallel Routes is that a default file is required. The docs say

On refresh, Next.js will render a default.js for @analytics. If default.js doesn't exist, a 404 is rendered instead.

We don't want to render a 404 page so we need to add app/@breadcrumbs/default.tsx. In this case we're going to render an empty fragment to satisfy the requirements.

export default function Default() {
return (<></>)
}
export default function Default() {
return (<></>)
}

layout.tsx

Rendering the slot is covered more extensively in the Parallel Routes documentation linked above but a way to think about it is that children is a special slot that is automatically provided. To add our defined slots, we can mimic the way children are passed into the layout. We'll change our root layout at app/layout.tsx.

export default function Layout({ children, breadcrumbs }: { children: ReactNode, breadcrumbs: ReactNode }) {
return (
<html lang="en">
<body>
{breadcrumbs}
{children}
</body>
</html>
);
}
export default function Layout({ children, breadcrumbs }: { children: ReactNode, breadcrumbs: ReactNode }) {
return (
<html lang="en">
<body>
{breadcrumbs}
{children}
</body>
</html>
);
}

Nested Dynamic Routes#

The Pull Request I referenced at the start added the functionality for deeply nested dynamic routes to work with Parallel Routes so let's add in some routes to test this feature out. Let's go with names and add app/[first]/[middle]/[last]/page.tsx. We don't need to add anything to this page so we'll return a simple message to indicate where we're at.

export default function Page() {
return (
<div>Hello from Nested Dynamic</div>
)
}
export default function Page() {
return (
<div>Hello from Nested Dynamic</div>
)
}

With this route in place, we can visit http://localhost:3000/Joseph/Francis/Tribbiani and we'll see rendering in @breadcrumbs [ 'Joseph', 'Francis', 'Tribbiani' ] logged from the server. This is great and we'll be able to build our breadcrumbs for this page.

Static + Dynamic Routes#

Since the original publish date of this article, a new Pull Request, Provide non-dynamic segments to catch-all parallel routes, has been added to Next.js which makes this functionality in this section possible.

I wanted to add this section because my personal site, which you're reading this post on, does not have a set of deeply nested dynamic routes, instead I have a blog/ route with a single dynamic route [slug] underneath it. To demonstrate this, we can add app/blog/[slug]/page.tsx.

export default function Page() {
return (
<div>Hello from Blog Slug</div>
)
}
export default function Page() {
return (
<div>Hello from Blog Slug</div>
)
}

If we visit http://localhost:3000/blog/new-blog-post we'll see rendering in @breadcrumbs [ 'blog', 'new-blog-post' ]. Next.js is properly adding the static blog path into our params so we're able to build out our breadcrumbs for the entire site with a single parallel route!

shadcn/ui Breadcrumb#

I use shadcn/ui for the components on this website and will be laying out how to use the route parameters to build the breadcrumbs using components from it. However, the same principles should apply to components from any library. We'll be using the Breadcrumb to build out our own Breadcrumbs component. Given that we have an array of nested routes (from our logged output above) we'll need to construct longer and longer href attributes to pass into the BreadcrumbLink component. I decided to use a regular old for loop because I've been doing a bunch of data structures practice but if you want to use routes.forEach, go for it; either way, the concept is the same. We will build out clickable links for every part of the route except for the final one, which will be just a static representation of the current page as a BreadcrumbPage.

import React, {ReactElement} from "react";

import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb";

export function Breadcrumbs({routes = []}: {routes: string[]}) {
let fullHref: string | undefined = undefined;
const breadcrumbItems: ReactElement[] = [];
let breadcrumbPage: ReactElement = (<></>);

for(let i = 0; i < routes.length; i++) {
const route = routes[i];
let href;

href = fullHref ? `${fullHref}/${route}` : `/${route}`
fullHref = href

if (i === routes.length-1) {
breadcrumbPage = (
<BreadcrumbItem>
<BreadcrumbPage>{route}</BreadcrumbPage>
</BreadcrumbItem>
)
} else {
breadcrumbItems.push(
<React.Fragment key={href}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={href}>{route}</BreadcrumbLink>
</BreadcrumbItem>
</React.Fragment>
)
}
}

return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbItems}
<BreadcrumbSeparator />
{breadcrumbPage}
</BreadcrumbList>
</Breadcrumb>
)
}
import React, {ReactElement} from "react";

import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb";

export function Breadcrumbs({routes = []}: {routes: string[]}) {
let fullHref: string | undefined = undefined;
const breadcrumbItems: ReactElement[] = [];
let breadcrumbPage: ReactElement = (<></>);

for(let i = 0; i < routes.length; i++) {
const route = routes[i];
let href;

href = fullHref ? `${fullHref}/${route}` : `/${route}`
fullHref = href

if (i === routes.length-1) {
breadcrumbPage = (
<BreadcrumbItem>
<BreadcrumbPage>{route}</BreadcrumbPage>
</BreadcrumbItem>
)
} else {
breadcrumbItems.push(
<React.Fragment key={href}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href={href}>{route}</BreadcrumbLink>
</BreadcrumbItem>
</React.Fragment>
)
}
}

return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/">Home</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbItems}
<BreadcrumbSeparator />
{breadcrumbPage}
</BreadcrumbList>
</Breadcrumb>
)
}

Now that we have our Breadcrumbs component we can update our slot to render it.

// app/@breadcrumbs/[...catchAll]/page.tsx
import { Breadcrumbs } from "@/components/breadcrumbs";
type Props = {
params: {
catchAll: string[]
}
}
export default function BreadcrumbSlot({params: { catchAll } }: Props) {
return <Breadcrumbs routes={catchAll} />
}
// app/@breadcrumbs/[...catchAll]/page.tsx
import { Breadcrumbs } from "@/components/breadcrumbs";
type Props = {
params: {
catchAll: string[]
}
}
export default function BreadcrumbSlot({params: { catchAll } }: Props) {
return <Breadcrumbs routes={catchAll} />
}

Conclusion#

The final file structure of the application looks like this

/
app/
[first]/
[middle]/
[last]/
page.tsx
blog/
[slug]/
page.tsx
@breadcrumb/
[...catchAll]/
page.tsx
default.tsx
/
app/
[first]/
[middle]/
[last]/
page.tsx
blog/
[slug]/
page.tsx
@breadcrumb/
[...catchAll]/
page.tsx
default.tsx

I hope you were able to learn a little bit more about how the App Router works with Parallel Routes and how slots can be used to render different data depending on the route. I think this is a really powerful concept that I'll be exploring more in the future.