DEV Community

Cover image for React App with Nested Routes and Breadcrumbs
Dima Snisarenko
Dima Snisarenko

Posted on

React App with Nested Routes and Breadcrumbs

It was surprising to me to discover the absence of an adequate example of a React application with nested routes, automatically generated navigation, and breadcrumbs. All of the examples I could find require copypasting code to some extent. I'll try to fill in this gap and create an application satisfying the following criteria:

  • routing with react-router-dom
  • configurable nested routes
  • automatically generated navigation and breadcrumbs
  • DRY

The working example is available on GitHub: https://github.com/sneas/react-nested-routes-example

Routes

The most obvious way to build routes is to directly put them into the markup:

<Router>
  <Route path="/about">
    <About />
  </Route>
  <Route path="/users">
    <Users />
  </Route>
  <Route path="/">
    <Home />
  </Route>
</Router>
Enter fullscreen mode Exit fullscreen mode

It's also possible to store routes in an array and render them in a loop.

const routes = [
  {
    path: "/about",
    component: About
  },
  {
    path: "/users",
    component: Users
  },
  {
    path: "/",
    component: Home
  }
];

return (
  <Router>
    {routes.map(route => (
      <Route path={route.path} component={route.component} />
    ))}
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

Let's take this into consideration to build a router with a nested structure.

const routes = [
  {
    path: "/",
    component: Home,
    routes: [
      {
        path: "/about",
        component: About,
        routes: [
          {
            path: "/about/our-team",
            component: OurTeam
          }
        ]
      },
      {
        path: "/users",
        component: Users
      },
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode

Now we need to loop through the nested structure to output all the routes. This can be achieved by flattening our tree structure.

const flattenRoutes = routes =>
  routes
    .map(route => [route.routes ? flattenRoutes(route.routes) : [], route])
    .flat(Infinity);

const routes = [
  // Same as in previous snippet
];

return (
  <Router>
    {flattenRoutes(routes).map(route => (
      <Route path={route.path} component={route.component} />
    ))}
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

It's worth noting that flattenRoutes puts more specific routes closer to the beginning of the array:

  1. /about/our-team
  2. /about
  3. /users
  4. /

This will help us to use the parent route as a fallback when a child route can't be found. For example, opening /about/non-existing-page will end up routing user to /about component.

Now let's DRY things up a little bit and automatically generate prefix for each individual route based on its parent. Instead of "/about/our-teams" we will only need to store "/our-teams".

const combinePaths = (parent, child) =>
  `${parent.replace(/\/$/, "")}/${child.replace(/^\//, "")}`;

const buildPaths = (navigation, parentPath = "") =>
  navigation.map(route => {
    const path = combinePaths(parentPath, route.path);

    return {
      ...route,
      path,
      ...(route.routes && { routes: buildPaths(route.routes, path) })
    };
  });

const routes = [
  {
    path: "/",
    component: Home,
    routes: [
      {
        path: "/about",
        component: About,
        routes: [
          {
            path: "/our-team",
            component: OurTeam
          }
        ]
      },
      {
        path: "/users",
        component: Users
      },
    ]
  }
];

const flattenRoutes = routes =>
  routes
    .map(route => [route.routes ? flattenRoutes(route.routes) : [], route])
    .flat(Infinity);

return (
  <Router>
    {flattenRoutes(buildPaths(routes)).map(route => (
      <Route path={route.path} component={route.component} />
    ))}
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

Nested Menu

Let's create a nested menu for each page. In order for the nested menu to be visible on every page, we can create a single Page container. The Page container will hold the menu, breadcrumbs, and page contents.

const Page = ({ route }) => {
  // Let's output only page contents for now and 
  // take care of the menu and breadcrumbs later
  const PageBody = route.component;
  return <PageBody />;
};

return (
  <Router>
    {flattenRoutes(buildPaths(routes)).map(route => (
      {routes.map(route => (
        <Route key={route.path} path={route.path}>
          <Page route={route} />
        </Route>
      ))}
    ))}
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

Page container receives the current route prop. This prop will be used to build a nested menu and breadcrumbs.

The nested menu for a particular page consists of menus of its parents up to the root. To build a nested menu for a particular page, each route must know its parent.

const setupParents = (routes, parentRoute = null) =>
  routes.map(route => {
    const withParent = {
      ...route,
      ...(parentRoute && { parent: parentRoute })
    };

    return {
      ...withParent,
      ...(withParent.routes && {
        routes: setupParents(withParent.routes, withParent)
      })
    };
  });

// ...

return (
  <Router>
    {flattenRoutes(setupParents(buildPaths(routes))).map(route => (
      {routes.map(route => (
        <Route key={route.path} path={route.path}>
          <Page route={route} />
        </Route>
      ))}
    ))}
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

After parents of each page have been set, they can be used to our advantage of building nested menus.

const Menu = ({ routes }) => (
  <nav className="menu">
    {routes.map((route, index) => (
      <NavLink key={index} to={route.path}>
        {route.label}
      </NavLink>
    ))}
  </nav>
);

const pathTo = route => {
  if (!route.parent) {
    return [route];
  }

  return [...pathTo(route.parent), route];
};

const NestedMenu = ({ route }) => (
  <>
    {pathTo(route)
      .filter(r => r.routes)
      .map((r, index) => (
        <Menu key={index} routes={r.routes} />
      ))}
  </>
);

const Page = ({ route }) => {
  const PageBody = route.component;
  return (
    <>
      <NestedMenu route={route} />
      <PageBody />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

We've created 2 components: NestedMenu and Menu. The NestedMenu component specializes in rendering the entire nested menu for a particular route. It loops through the list of parent routes from the root to the specified route. The list is provided by pathTo(route) function. The navigation for an individual route is rendered by Menu component.

Breadcrumbs

For the breadcrumbs, we can use a similar approach as we used to create the nested menu.

const Breadcrumbs = ({ route }) => (
  <nav className="breadcrumbs">
    {pathTo(route).map((crumb, index, breadcrumbs) => (
      <div key={index} className="item">
        {index < breadcrumbs.length - 1 && (
          <NavLink to={crumb.path}>{crumb.label}</NavLink>
        )}
        {index === breadcrumbs.length - 1 && crumb.label}
      </div>
    ))}
  </nav>
);

const Page = ({ route }) => {
  const PageBody = route.component;
  return (
    <>
      <NestedMenu route={route} />
      {route.parent && <Breadcrumbs route={route} />}
      <PageBody />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The Breadcrumb component also loops through the list of routes provided by the previously described pathTo(route) function. It makes sure the "current" route to be rendered as a text and parent routes to be rendered as a link:

{index < breadcrumbs.length - 1 && (
  <NavLink to={crumb.path}>{crumb.label}</NavLink>
)}
{index === breadcrumbs.length - 1 && crumb.label}
Enter fullscreen mode Exit fullscreen mode

We don't want to render breadcrumbs for the root route. The root route could be determined by the absence of parents: {route.parent && <Breadcrumbs route={route} />}.

Conclusion

The provided solution satisfies all the previously defined criteria:

  • the app uses react-router-dom
  • the nested routes are configured as a tree-like structure
  • the navigation and breadcrumbs are rendered automatically based on the configuration
  • the app code and configuration does not repeat itself

Top comments (0)