Building Dynamic Breadcrumbs in Next.js App Router
Sedat Can Uygur
24 Mar 2025
Sedat Can Uygur
24 Mar 2025
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 this tweet from @fredkisss pointing to a Pull Request that
had just landed in Next.js.
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.
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.
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/Sedat/Can/Uygur and we'll see
rendering in @breadcrumbs [ 'Sedat', 'Can', 'Uygur' ] logged from the server. This is great and we'll
be able to build our breadcrumbs for this page.
One thing I discovered while writing this post is that the catch-all does not cover routes which have static routes in
them. I wanted to add this section in because this website does not have a set of deeply nested dynamic routes, instead
I have a blog/ route with a single dynamic route [slug] underneath it and I was hoping to use this feature to add
additional navigation. 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 [ 'new-blog-post' ]. That
isn't what I expected and it is unfortunate that 'blog' isn't added to our catch-all. If we want to render breadcrumbs
that include blog then we'll have to add an additional page in our @breadcrumb slot.
We'll make another file at app/@breadcrumbs/blog/[slug]/page.tsx.
type Props = {
params: {
slug: string
}
}
export default function BreadcrumbsSlot({params: { slug } }: Props) {
console.log("rendering in @breadcrumbs", slug)
return <div>placeholder</div>
}
type Props = {
params: {
slug: string
}
}
export default function BreadcrumbsSlot({params: { slug } }: Props) {
console.log("rendering in @breadcrumbs", slug)
return <div>placeholder</div>
}
Now we will be able to customize the breadcrumbs specifically for our blog route.
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[]}) {
Now that we have our Breadcrumbs component we can update our slots 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} />
}
// app/@breadcrumbs/blog/[slug]/page.tsx
import { Breadcrumbs } from "@/components/breadcrumbs";
type Props = {
params: {
slug: string
}
}
export default function BreadcrumbSlot({params: { slug } }: Props) {
return <Breadcrumbs routes={["blog", slug]} />
}
// app/@breadcrumbs/blog/[slug]/page.tsx
import { Breadcrumbs } from "@/components/breadcrumbs";
type Props = {
params: {
slug: string
}
}
export default function BreadcrumbSlot({params: { slug } }: Props) {
return <Breadcrumbs routes={["blog", slug]} />
}
For the catch-all, we only need to pass the catchAll array into Breadcrumbs, however for our blog implementation we
have to append the "blog" portion ourselves.
The final file structure of the application looks like this
/
app/
[first]/
[middle]/
[last]/
page.tsx
blog/
[slug]/
page.tsx
@breadcrumb/
[...catchAll]/
page.tsx
blog/
[slug]/
page.tsx
default.tsx
/
app/
[first]/
[middle]/
[last]/
page.tsx
blog/
[slug]/
page.tsx
@breadcrumb/
[...catchAll]/
page.tsx
blog/
[slug]/
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.