DEV Community

Cover image for Building Dynamic Breadcrumbs in NextJS
Daniel Starner
Daniel Starner

Posted on • Updated on

Building Dynamic Breadcrumbs in NextJS

Breadcrumbs are a website navigation tool that allows users to see their current page's "stack" of how it is nested under any parent pages. Users can then jump back to a parent page by clicking the associated breadcrumb link. These "Crumbs" increase the User Experience of the application, making it easier for the users to navigate nested pages efficiently and effectively.

Example Breadcrumbs

Breadcrumbs are popular enough while building a web dashboard or application that you may have considered adding them. Generating these breadcrumb links efficiently and with the appropriate context is key to an improved user experience.

Let's build an intelligent NextBreadcrumbs React component that will parse the current route and create a dynamic breadcrumbs display that can handle both static & dynamic routes efficiently.

My projects usually revolve around Nextjs and MUI (formerly Material-UI), so that is the angle that I am going to approach this problem from, although the solution should work for any Nextjs-related application.

Static Route Breadcrumbs

To begin, our NextBreadcrumbs component will only handle static routes, meaning that our project has only static pages defined in the pages directory.

The following are examples of static routes because they do not contain ['s and] 's in the route names, meaning the directory structure lines up 1:1 precisely with the expected URLs that they serve.

  • pages/index.js --> /
  • pages/about.js --> /about
  • pages/my/super/nested/route.js --> /my/super/nested/route

The solution will be extended to handle dynamic routes later.

Defining the Basic Component

We can start with the fundamental component that uses the MUI Breadcrumbs component as a baseline.

import Breadcrumbs from '@mui/material/Breadcrumbs';
import * as React from 'react';

export default function NextBreadcrumbs() {
  return (
    <Breadcrumbs aria-label="breadcrumb" />
  );
}
Enter fullscreen mode Exit fullscreen mode

The above creates the basic structure of the NextBreadcrumbs React component, imports the correct dependencies, and renders an empty Breadcrumbs MUI component.

We can then add in the next/router hooks, which will allow us to build the breadcrumbs from the current route.

We also create a Crumb component that will be used to render each link. This is a pretty dumb component for now, except that it will render basic text instead of a link for the last breadcrumb.

In a situation like /settings/notifications, it would render as the following:

Home (/ link) > Settings (/settings link) > Notifications (no link)
Enter fullscreen mode Exit fullscreen mode

The user is already on the last breadcrumb's page, so there is no need to link out to the same page. All the other crumbs are rendered as links to be clicked.

import Breadcrumbs from '@mui/material/Breadcrumbs';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import { useRouter } from 'next/router';
import React from 'react';


export default function NextBreadcrumbs() {
  // Gives us ability to load the current route details
  const router = useRouter();

  return (
    <Breadcrumbs aria-label="breadcrumb" />
  );
}


// Each individual "crumb" in the breadcrumbs list
function Crumb({ text, href, last=false }) {
  // The last crumb is rendered as normal text since we are already on the page
  if (last) {
    return <Typography color="text.primary">{text}</Typography>
  }

  // All other crumbs will be rendered as links that can be visited 
  return (
    <Link underline="hover" color="inherit" href={href}>
      {text}
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

We can then dive back into the NextBreadcrumbs component to generate the breadcrumbs from the route with this layout. Some existing code will start to be omitted to keep the code pieces smaller. The full example is shown below.

We will generate a list of breadcrumb objects that contain the information to be rendered by each Crumb element. Each breadcrumb will be created by parsing the Nextjs router's asPath property, which is a string containing the route as shown in the browser URL bar.

We will strip any query parameters, such as ?query=value, from the URL to simplify the breadcrumb creation process.

export default function NextBreadcrumbs() {
  // Gives us ability to load the current route details
  const router = useRouter();

  function generateBreadcrumbs() {
    // Remove any query parameters, as those aren't included in breadcrumbs
    const asPathWithoutQuery = router.asPath.split("?")[0];

    // Break down the path between "/"s, removing empty entities
    // Ex:"/my/nested/path" --> ["my", "nested", "path"]
    const asPathNestedRoutes = asPathWithoutQuery.split("/")
                                                 .filter(v => v.length > 0);

    // Iterate over the list of nested route parts and build
    // a "crumb" object for each one.
    const crumblist = asPathNestedRoutes.map((subpath, idx) => {
      // We can get the partial nested route for the crumb
      // by joining together the path parts up to this point.
      const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
      // The title will just be the route string for now
      const title = subpath;
      return { href, text }; 
    })

    // Add in a default "Home" crumb for the top-level
    return [{ href: "/", text: "Home" }, ...crumblist];
  }

  // Call the function to generate the breadcrumbs list
  const breadcrumbs = generateBreadcrumbs();

  return (
    <Breadcrumbs aria-label="breadcrumb" />
  );
}
Enter fullscreen mode Exit fullscreen mode

With this list of breadcrumbs, we can now render them using the Breadcrumbs and Crumb components. As previously mentioned, only the return portion of our component is shown for brevity.

  // ...rest of NextBreadcrumbs component above...
  return (
    {/* The old breadcrumb ending with '/>' was converted into this */}
    <Breadcrumbs aria-label="breadcrumb">
      {/*
        Iterate through the crumbs, and render each individually.
        We "mark" the last crumb to not have a link.
      */}
      {breadcrumbs.map((crumb, idx) => (
        <Crumb {...crumb} key={idx} last={idx === breadcrumbs.length - 1} />
      ))}
    </Breadcrumbs>
  );
Enter fullscreen mode Exit fullscreen mode

This should start generating some very basic - but working - breadcrumbs on our site once rendered; /user/settings/notifications would render as

Home > user > settings > notifications
Enter fullscreen mode Exit fullscreen mode

Memoizing Generated Breadcrumbs

There is a quick improvement that we can make before going further, though. The breadcrumb list is recreated every time the component re-renders, so we can memoize the crumb list for a given route to save some performance. We can wrap our generateBreadcrumbs function call in the useMemo React hook.

  const router = useRouter();

  // this is the same "generateBreadcrumbs" function, but placed
  // inside a "useMemo" call that is dependent on "router.asPath"
  const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
    const asPathWithoutQuery = router.asPath.split("?")[0];
    const asPathNestedRoutes = asPathWithoutQuery.split("/")
                                                 .filter(v => v.length > 0);

    const crumblist = asPathNestedRoutes.map((subpath, idx) => {
      const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
      return { href, text: subpath }; 
    })

    return [{ href: "/", text: "Home" }, ...crumblist];
  }, [router.asPath]);

  return // ...rest below...
Enter fullscreen mode Exit fullscreen mode

Improving Breadcrumb Text Display

Before we start incorporating dynamic routes, we can clean this current solution up more by including a nice way to change the text shown for each crumb generated.

Right now, if we have a path like /user/settings/notifications, then it will show:

Home > user > settings > notifications
Enter fullscreen mode Exit fullscreen mode

...which is not very appealing. We can provide a function to the NextBreadcrumbs component to generate a more user-friendly name for each of these nested route crumbs.


const _defaultGetDefaultTextGenerator= path => path

export default function NextBreadcrumbs({ getDefaultTextGenerator=_defaultGetDefaultTextGenerator }) {
  const router = useRouter();

  // Two things of importance:
  // 1. The addition of getDefaultTextGenerator in the useMemo dependency list
  // 2. getDefaultTextGenerator is now being used for building the text property
  const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
    const asPathWithoutQuery = router.asPath.split("?")[0];
    const asPathNestedRoutes = asPathWithoutQuery.split("/")
                                                 .filter(v => v.length > 0);

    const crumblist = asPathNestedRoutes.map((subpath, idx) => {
      const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
      return { href, text: getDefaultTextGenerator(subpath, href) }; 
    })

    return [{ href: "/", text: "Home" }, ...crumblist];
  }, [router.asPath, getDefaultTextGenerator]);

  return ( // ...rest below

Enter fullscreen mode Exit fullscreen mode

Then our parent component can have something like the following: to title-ize the subpaths, or maybe even replace them with a new string.

{/* Assume that `titleize` is written and works appropriately */}
<NextBreadcrumbs getDefaultTextGenerator={path => titleize(path)} />
Enter fullscreen mode Exit fullscreen mode

This implementation would then result in the following breadcrumbs. The complete code example at the bottom has more examples of this.

Home > User > Settings > Notifications
Enter fullscreen mode Exit fullscreen mode

Nextjs Dynamic Routes

Nextjs's router allows for including dynamic routes that uses Pattern Matching to enable the URLs to have slugs, UUIDs, and other dynamic values that will then be passed to your views.

For example, if your Nextjs application has a page component at pages/post/[post_id].js, then the routes /post/1 and /post/abc will match it.

For our breadcrumbs component, we would like to show the name of the associated post instead of just its UUID. This means that the component will need to dynamically look up the post data based on the nested URL route path and regenerate the text of the associated crumb.

Right now, if you visit /post/abc, you would see breadcrumbs that look like

post > abc
Enter fullscreen mode Exit fullscreen mode

but if the post with UUID has a title of My First Post, then we want to change the breadcrumbs to say

post > My First Post
Enter fullscreen mode Exit fullscreen mode

Let's dive into how that can happen using async functions.

Nextjs Router: asPath vs pathname

The next/router router instance in our code has two useful properties for our NextBreadcrumbs component; asPath and pathname. The router asPath is the URL path as shown directly in the browser's URL bar. The pathname is a more internal version of the URL that has the dynamic parts of the path replaced with their [parameter] components.

For example, consider the path /post/abc from above.

  • The asPath would be /post/abc as the URL is shown
  • The pathname would be /post/[post_id] as our pages directory dictates

We can use these two URL path variants to build a way to dynamically fetch information about the breadcrumb, so we can show more contextually appropriate information to the user.

There is a lot going on below, so please re-read it and the helpful notes below a few times over if needed.


const _defaultGetTextGenerator = (param, query) => null;
const _defaultGetDefaultTextGenerator = path => path;

// Pulled out the path part breakdown because its
// going to be used by both `asPath` and `pathname`
const generatePathParts = pathStr => {
  const pathWithoutQuery = pathStr.split("?")[0];
  return pathWithoutQuery.split("/")
                         .filter(v => v.length > 0);
}

export default function NextBreadcrumbs({
  getTextGenerator=_defaultGetTextGenerator,
  getDefaultTextGenerator=_defaultGetDefaultTextGenerator
}) {
  const router = useRouter();

  const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
    const asPathNestedRoutes = generatePathParts(router.asPath);
    const pathnameNestedRoutes = generatePathParts(router.pathname);

    const crumblist = asPathNestedRoutes.map((subpath, idx) => {
      // Pull out and convert "[post_id]" into "post_id"
      const param = pathnameNestedRoutes[idx].replace("[", "").replace("]", "");

      const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
      return {
        href, textGenerator: getTextGenerator(param, router.query),
        text: getDefaultTextGenerator(subpath, href)
      }; 
    })

    return [{ href: "/", text: "Home" }, ...crumblist];
  }, [router.asPath, router.pathname, router.query, getTextGenerator, getDefaultTextGenerator]);

  return ( // ...rest below

Enter fullscreen mode Exit fullscreen mode
  • The asPath breakdown was moved to a generatePathParts function since the same logic is used for both router.asPath and router.pathname.
  • Determine the param'eter that lines up with the dynamic route value, soabcwould result inpost_id`.
  • The nested route param'eter and all associated query values (router.query) are passed to a provided getTextGenerator which will return either a null value or a Promise` response that should return the dynamic string to use in the associated breadcrumb.
  • The useMemo dependency array has more dependencies added; router.pathname, router.query, and getTextGenerator.

Finally, we need to update the Crumb component to use this textGenerator value if provided for the associated crumb object.

function Crumb({ text: defaultText, textGenerator, href, last=false }) {

  const [text, setText] = React.useState(defaultText);

  useEffect(async () => {
    // If `textGenerator` is nonexistent, then don't do anything
    if (!Boolean(textGenerator)) { return; }
    // Run the text generator and set the text again
    const finalText = await textGenerator();
    setText(finalText);
  }, [textGenerator]);

  if (last) {
    return <Typography color="text.primary">{text}</Typography>
  }

  return (
    <Link underline="hover" color="inherit" href={href}>
      {text}
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

The breadcrumbs can now handle both static routes and dynamic routes cleanly, with the potential to display user-friendly values. While the above code is the component's business logic, this can all be used with a parent component that looks like the final example below.

Full Example

// NextBreadcrumbs.js

const _defaultGetTextGenerator = (param, query) => null;
const _defaultGetDefaultTextGenerator = path => path;

// Pulled out the path part breakdown because its
// going to be used by both `asPath` and `pathname`
const generatePathParts = pathStr => {
  const pathWithoutQuery = pathStr.split("?")[0];
  return pathWithoutQuery.split("/")
                         .filter(v => v.length > 0);
}

export default function NextBreadcrumbs({
  getTextGenerator=_defaultGetTextGenerator,
  getDefaultTextGenerator=_defaultGetDefaultTextGenerator
}) {
  const router = useRouter();

  const breadcrumbs = React.useMemo(function generateBreadcrumbs() {
    const asPathNestedRoutes = generatePathParts(router.asPath);
    const pathnameNestedRoutes = generatePathParts(router.pathname);

    const crumblist = asPathNestedRoutes.map((subpath, idx) => {
      // Pull out and convert "[post_id]" into "post_id"
      const param = pathnameNestedRoutes[idx].replace("[", "").replace("]", "");

      const href = "/" + asPathNestedRoutes.slice(0, idx + 1).join("/");
      return {
        href, textGenerator: getTextGenerator(param, router.query),
        text: getDefaultTextGenerator(subpath, href)
      }; 
    })

    return [{ href: "/", text: "Home" }, ...crumblist];
  }, [router.asPath, router.pathname, router.query, getTextGenerator, getDefaultTextGenerator]);

  return (
    <Breadcrumbs aria-label="breadcrumb">
      {breadcrumbs.map((crumb, idx) => (
        <Crumb {...crumb} key={idx} last={idx === breadcrumbs.length - 1} />
      ))}
    </Breadcrumbs>
  );
}


function Crumb({ text: defaultText, textGenerator, href, last=false }) {

  const [text, setText] = React.useState(defaultText);

  useEffect(async () => {
    // If `textGenerator` is nonexistent, then don't do anything
    if (!Boolean(textGenerator)) { return; }
    // Run the text generator and set the text again
    const finalText = await textGenerator();
    setText(finalText);
  }, [textGenerator]);

  if (last) {
    return <Typography color="text.primary">{text}</Typography>
  }

  return (
    <Link underline="hover" color="inherit" href={href}>
      {text}
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

An example of this NextBreadcrumbs being used can be seen below. Note that useCallback is used to create only one reference to each helper function which will prevent unnecessary re-renders of the breadcrumbs when/if the page layout component re-rendered. Of course, you could move this out to the top-level scope of the file, but I don't like to pollute the global scope like that.

// MyPage.js (Parent Component)

import React from 'react';
import NextBreadcrumbs from "./NextBreadcrumbs";


function MyPageLayout() {

  // Either lookup a nice label for the subpath, or just titleize it
  const getDefaultTextGenerator = React.useCallback((subpath) => {
    return {
      "post": "Posts",
      "settings": "User Settings",
    }[subpath] || titleize(subpath);
  }, [])

  // Assuming `fetchAPI` loads data from the API and this will use the
  // parameter name to determine how to resolve the text. In the example,
  // we fetch the post from the API and return it's `title` property
  const getTextGenerator = React.useCallback((param, query) => {
    return {
      "post_id": () => await fetchAPI(`/posts/${query.post_id}/`).title,
    }[param];
  }, []);

  return () {
    <div>
      {/* ...Whatever else... */}
      <NextBreadcrumbs
        getDefaultTextGenerator={getDefaultTextGenerator}
        getTextGenerator={getTextGenerator}
      />
      {/* ...Whatever else... */}
    </div>
  }

}

Enter fullscreen mode Exit fullscreen mode

This is one of my more in-depth and technical posts, so I hope you enjoyed it. Please comment or reach out regarding any issues to ensure consistency and correctness. Hopefully, this post taught you a few strategies or concepts about Nextjs.

If you liked this or my other posts, please subscribe to my brand new Newsletter for weekly tech updates!

Top comments (11)

Collapse
 
isaactait profile image
Isaac Tait

I am getting an error re: async:

Effect callbacks are synchronous to prevent race conditions. Put the async function inside:
useEffect(() => {
  async function fetchData() {
    // You can await here
    const response = await MyAPI.getData(someId);
    // ...
  }
  fetchData();
}, [someId]); // Or [] if effect doesn't need props or state

Learn more about data fetching with Hooks: https://reactjs.org/link/hooks-data-fetchingeslintreact-hooks/exhaustive-deps
Enter fullscreen mode Exit fullscreen mode

I followed the eslint guidance to fix and now it says that useEffect is not defined. Per chance do you have the full code I could take a look at to see what I am doing wrong? Thanks for writing this btw. Great stuff. Cheers!

Collapse
 
florentinog9 profile image
FlorentinoG9

I fix it by doing this instead

inside the Crumb Component before the if( last) ....

    const router = useRouter()
    const [text, setText] = useState(defaultText)

    useEffect(() => {
        if ( !Boolean(textGenerator) ) return setText(defaultText)

        async function fetchData() {
            const currText = await textGenerator()
            setText(currText)
        }

        fetchData()

    }, [defaultText, textGenerator])
Enter fullscreen mode Exit fullscreen mode
Collapse
 
theunreal profile image
Eliran Elnasi

Nice one! But not sure what asPathParts - there is no definition of it and it's being used.

Collapse
 
dan_starner profile image
Daniel Starner

Oops! You are correct. That was an artifact of my copying from my “real” code and trying to refactor the names to be more tutorial friendly. I believe I fixed it.

Collapse
 
stackusman profile image
stack-usman


useEffect(() => {
let splited = router.asPath.split('/')
let bread = document.getElementById("#p");
for(let i = 1; i < splited.length; i++){
bread.innerText += i > 1 ? '/' : ''
bread.innerText += splited[i]
}
document.body.appendChild(bread)
},[router])

Nice solution you provide.
but can you suggest improvement for this.

Collapse
 
jcodin profile image
jCodin

Hey! Nice tutorial, thank you for that, great work!

I wonder how the breadcrumb component gets notified about the changing route, because it seems like you don´t even have to listen to some events or similiar.

Could you tell me how the breadcrumb component knows when to update itself? (and which part of your provided code is responsible for that?)

Kind regards,
Jendrik

Collapse
 
florentinog9 profile image
FlorentinoG9

it updates it self with the useMemo's dependencies array [router.asPath, router.pathname, router.query, getTextGenerator, getDefaultTextGenerator]

I think you could take off the router.query from the dependencies since the router.query is an object and will not know if the values of that object are changing but will have to try that

Collapse
 
sdoxsee profile image
Stephen Doxsee

This helped. I didn't need text generators, useEffect, etc. because I just pass in the param and query to the breadcrumbitem and conditionally pull dynamic data based on the param name (e.g. [post_id]) and its value from the query. This post really helped get me going. Thanks!

Collapse
 
sunflowertc profile image
sunflower-tc

Hi, can you provide me with the git address of the code? Thank you very much!

Collapse
 
janoschherrmann profile image
Janosch Herrmann

Hey @dan_starner, it seems like this wouldn't work with catchall routes in Next, as creating the crumblist throws an error.

Collapse
 
copernico profile image
Steve

This code is not working for me... it shows all kind of errors.