Collapsible Sidebar with HeroUI and Next.js (TypeScript)

Collapsible dashboard sidebar using Next.js and HeroUI. Let’s build it together. We’ll keep it friendly, fast, and production-ready. You’ll ship a responsive Accordion sidebar, wire it to dynamic routes, and highlight active links. Everything uses the App Router and TypeScript.

What You’ll Build

You’ll create a dashboard layout with a collapsible sidebar. Each section collapses using HeroUI’s Accordion. Items inside each section link to dynamic routes in the app directory. The sidebar adapts for mobile with an off-canvas pattern. Active links style correctly based on the current path. By the end, you’ll have a foundation you can extend safely and confidently.

Prerequisites and Setup

You now have HeroUI initialized. Time to design the layout.

Directory structure

nextjs-dashboard-project/
├── app/
│   ├── (dashboard)/
│   │   └── dashboard/
│   │       ├── analytics/
│   │       │   ├── cohorts/
│   │       │   │   └── page.tsx
│   │       │   ├── funnels/
│   │       │   │   └── page.tsx
│   │       │   └── overview/
│   │       │       └── page.tsx
│   │       ├── settings/
│   │       │   └── [[...slug]]/
│   │       │       └── page.tsx
│   │       ├── users/
│   │       │   ├── [segment]/
│   │       │   │   └── page.tsx
│   │       │   └── page.tsx
│   │       ├── layout.tsx        # Dashboard layout (Sidebar + MobileSidebar)
│   │       └── page.tsx          # Dashboard home
│   ├── layout.tsx                # Root layout (with Providers)
│   └── page.tsx                  # Landing/home page
│
├── components/
│   ├── mobile-sidebar.tsx
│   ├── providers.tsx
│   └── sidebar.tsx
│
├── data/
│   └── nav.tsx
│
├── types/
│   └── nav.ts
│
├── public/
│
├── styles/
│   └── globals.css
│
├── tsconfig.json
├── package.json
└── next.config.js

Information Architecture

Dashboards thrive on structure. We’ll model the sidebar as sections with nested items. Each item points to a route segment. App Router handles nested layouts and dynamic segments. This pattern scales well as features grow.

Create a small type model:

// types/nav.ts
export type NavItem = {
  label: string;
  href: string; // absolute path beginning with /dashboard/...
  icon?: React.ReactNode;
};

export type NavSection = {
  id: string;
  label: string;
  items: NavItem[];
};

Seed some sections:

// data/nav.tsx
import {NavSection} from "@/types/nav";
import {ChartBarIcon, Cog6ToothIcon, UsersIcon} from "@heroicons/react/24/outline";

export const NAV_SECTIONS: NavSection[] = [
  {
    id: "analytics",
    label: "Analytics",
    items: [
      {label: "Overview", href: "/dashboard/analytics/overview", icon: <ChartBarIcon className="h-4 w-4" />},
      {label: "Funnels", href: "/dashboard/analytics/funnels"},
      {label: "Cohorts", href: "/dashboard/analytics/cohorts"},
    ],
  },
  {
    id: "users",
    label: "Users",
    items: [
      {label: "All Users", href: "/dashboard/users", icon: <UsersIcon className="h-4 w-4" />},
      {label: "Segments", href: "/dashboard/users/segments"},
    ],
  },
  {
    id: "settings",
    label: "Settings",
    items: [
      {label: "General", href: "/dashboard/settings", icon: <Cog6ToothIcon className="h-4 w-4" />},
      {label: "Billing", href: "/dashboard/settings/billing"},
    ],
  },
];

Icons are optional. You can omit them if you prefer a minimalist look.

Layout Structure with App Router

We’ll create a dashboard layout that pins the sidebar on desktop. On mobile, the sidebar appears in a drawer. The App Router lets you scope layouts to route groups. This keeps the rest of the site lean and uncluttered.

Create app/(dashboard)/dashboard/layout.tsx:

// app/(dashboard)/dashboard/layout.tsx
import {Sidebar} from "@/components/sidebar";
import React from "react";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div suppressHydrationWarning className="min-h-dvh flex">
      <Sidebar />
      <main className="flex-1 min-w-0 p-6">{children}</main>
    </div>
  );
}

Add a basic page to confirm rendering:

// app/(dashboard)/dashboard/page.tsx
export default function DashboardHome() {
  return (
    <section>
      <h1 className="text-2xl font-semibold">Dashboard</h1>
      <p className="text-default-500 mt-2">
        Pick a section from the sidebar to begin.
      </p>
    </section>
  );
}

We’ll style with utility classes. Tailwind works great, yet HeroUI also provides tokens. Use whichever you prefer. Keep consistency across components.

App router explain

The file path app/(dashboard)/dashboard/layout.tsx is a specific convention within the Next.js App Router. Here’s what each part of the path signifies:

  • app: This is the root directory for the new App Router. All routes and their corresponding UI components must be placed inside this folder.
  • (dashboard): The parentheses around dashboard indicate a private folder. This means that (dashboard) does not affect the URL path. It’s used for organizational purposes, to group related route segments. In this case, it might be grouping all routes related to the dashboard section of the application. The URL for a page inside this folder, like a file at app/(dashboard)/dashboard/page.tsx, would simply be /dashboard.
  • dashboard: This is a route segment. It defines a part of the URL path. A file at app/(dashboard)/dashboard/page.tsx would be accessible at yoursite.com/dashboard.
  • layout.tsx: This is a special file that defines a UI component which wraps its child components. A layout component is shared across multiple pages within the same route segment and its children. The layout defined in app/(dashboard)/dashboard/layout.tsx will wrap all the pages and sub-layouts inside the dashboard segment, for example, app/(dashboard)/dashboard/page.tsx and app/(dashboard)/dashboard/settings/page.tsx. It’s a great place to put shared UI elements like a sidebar or a header for a specific section of your site.

Building the Accordion Sidebar

Time to implement the sidebar with HeroUI’s Accordion. The component manages collapse state and keyboard interactions. It keeps the UI accessible and predictable.

Create components/sidebar.tsx:

"use client";

import { Accordion, AccordionItem, Button, Link } from "@heroui/react";
import clsx from "clsx";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";

import { NAV_SECTIONS } from "@/data/nav";

export function Sidebar() {
  const pathname = usePathname();
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());

  // Expand the section that contains the active path.
  const defaultOpen = useMemo(() => {
    const found = NAV_SECTIONS.find((s) =>
      s.items.some((i) => pathname?.startsWith(i.href)),
    );

    return found ? new Set([found.id]) : new Set<string>();
  }, [pathname]);

  // Merge default open and current open to avoid snap shut when navigating.
  const keysToRender = openKeys.size ? openKeys : defaultOpen;

  return (
    <aside className="w-72 shrink-0 border-r border-default-200 p-4 hidden md:block">
      <div className="flex items-center justify-between mb-4">
        <span className="font-semibold">Your App</span>
        <Button
          isIconOnly
          aria-label="Collapse all"
          size="sm"
          variant="light"
          onPress={() => setOpenKeys(new Set())}
        >
          —
        </Button>
      </div>

      <Accordion
        isCompact
        selectedKeys={keysToRender}
        variant="splitted"
        onSelectionChange={(keys) => {
          // HeroUI gives a Set<string | number>.
          setOpenKeys(new Set(Array.from(keys).map(String)));
        }}
      >
        {NAV_SECTIONS.map((section) => (
          <AccordionItem
            key={section.id}
            aria-label={section.label}
            title={section.label}
          >
            <nav className="flex flex-col gap-1">
              {section.items.map((item) => {
                const active = pathname === item.href;

                return (
                  <Link
                    key={item.href}
                    aria-current={active ? "page" : undefined}
                    className={clsx(
                      "px-2 py-1 rounded-small text-sm flex items-center gap-2",
                      active
                        ? "bg-primary-50 text-primary-700"
                        : "text-foreground hover:bg-default-100",
                    )}
                    href={item.href}
                  >
                    {item.icon}
                    <span>{item.label}</span>
                  </Link>
                );
              })}
            </nav>
          </AccordionItem>
        ))}
      </Accordion>
    </aside>
  );
}

This component expands the section containing the active path. It also supports manual toggling. The variant="splitted" style gives each item a nice card look. Feel free to adjust spacing for your brand.

Let’s add a simple mobile experience. We’ll toggle the sidebar in a drawer. HeroUI offers primitives that compose well with CSS. We can use a sheet pattern with a conditional panel.

Create components/mobile-sidebar.tsx:

"use client";

import {
  Accordion,
  AccordionItem,
  Button,
  Link,
  Modal,
  ModalContent,
} from "@heroui/react";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";

import { NAV_SECTIONS } from "@/data/nav";

export function MobileSidebar() {
  const pathname = usePathname();
  const [open, setOpen] = useState(false);

  const defaultKey = useMemo(() => {
    const found = NAV_SECTIONS.find((s) =>
      s.items.some((i) => pathname?.startsWith(i.href)),
    );

    return found?.id;
  }, [pathname]);

  return (
    <>
      <div className="md:hidden sticky top-0 z-40 bg-background border-b border-default-200 p-2 flex justify-between">
        <Button size="sm" onPress={() => setOpen(true)}>
          Menu
        </Button>
        <span className="font-semibold">Your App</span>
      </div>

      <Modal isOpen={open} placement="top" size="full" onOpenChange={setOpen}>
        <ModalContent>
          <div className="p-4">
            <Button size="sm" variant="light" onPress={() => setOpen(false)}>
              Close
            </Button>
            <Accordion
              isCompact
              className="mt-4"
              defaultSelectedKeys={[defaultKey ?? ""]}
            >
              {NAV_SECTIONS.map((section) => (
                <AccordionItem key={section.id} title={section.label}>
                  <nav className="flex flex-col gap-1">
                    {section.items.map((item) => (
                      <Link
                        key={item.href}
                        href={item.href}
                        onClick={() => setOpen(false)}
                      >
                        {item.label}
                      </Link>
                    ))}
                  </nav>
                </AccordionItem>
              ))}
            </Accordion>
          </div>
        </ModalContent>
      </Modal>
    </>
  );
}

Finally, render the mobile component inside the dashboard layout. Place it before the main content to preserve reading order.

Launch the web application.

>npm run dev
http://localhost:3000/dashboard
Dashboard

Update app/(dashboard)/dashboard/layout.tsx:

import {Sidebar} from "@/components/sidebar";
import {MobileSidebar} from "@/components/mobile-sidebar";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-dvh">
      <MobileSidebar />
      <div className="flex">
        <Sidebar />
        <main className="flex-1 min-w-0 p-6">{children}</main>
      </div>
    </div>
  );
}

You now have a responsive, collapsible navigation. Let’s handle routing next.

Dashboard on Mobile
Menu on Mobile

Dynamic Routes the Easy Way

The App Router embraces nested segments. You rarely need complex client routing logic. Instead, model your pages by feature area.

Create analytics routes:

// app/(dashboard)/dashboard/analytics/overview/page.tsx
export default function OverviewPage() {
  return <h2 className="text-xl font-semibold">Analytics • Overview</h2>;
}
// app/(dashboard)/dashboard/analytics/funnels/page.tsx
export default function FunnelsPage() {
  return <h2 className="text-xl font-semibold">Analytics • Funnels</h2>;
}
// app/(dashboard)/dashboard/analytics/cohorts/page.tsx
export default function CohortsPage() {
  return <h2 className="text-xl font-semibold">Analytics • Cohorts</h2>;
}

For flexible sections, use dynamic segments. They make your sidebar future-proof. Add a user segment route:

// app/(dashboard)/dashboard/users/[segment]/page.tsx

export default async function UserSegmentPage({
  params,
}: {
  params: { segment: string };
}) {
  const { segment } = await params;

  return <h2 className="text-xl font-semibold">Users • {segment}</h2>;
}
// app/(dashboard)/dashboard/users/page.tsx
export default async function UserSegmentPage() {
  return <h2 className="text-xl font-semibold">All Users</h2>;
}

For catch-all paths, use [[...slug]]. This helps with nested settings pages:

// app/(dashboard)/dashboard/settings/[[...slug]]/page.tsx
type Props = { params: { slug?: string[] } };

export default async function SettingsCatchAll({ params }: Props) {
  const { slug } = await params;
  const path = "/" + (slug ?? []).join("/");

  return (
    <h2 className="text-xl font-semibold">Settings • {path || "general"}</h2>
  );
}

Your sidebar links will work out of the box. App Router merges layouts with the correct segment tree. Navigation remains quick and accessible.

Active Styling and Accessibility

Users deserve a clear wayfinding. We already highlighted the current link using usePathname. That covers style. Accessibility matters as well.

Accordion headings should announce state changes to assistive technology, such as screen readers. HeroUI handles ARIA attributes and keyboard controls by default. Items receive focus correctly when users tab through the list. Reasonable defaults reduce maintenance.

Still, you can improve semantics. Add aria-current="page" to the active link. Screen readers then announce the current page. Update the link:

<Link
  key={item.href}
  href={item.href}
  aria-current={active ? "page" : undefined}
  className={/* same as before */}
>
  {item.icon}
  <span>{item.label}</span>
</Link>

Keyboard users deserve predictable behavior. Ensure the first interactive element appears early in the DOM. Our mobile sheet keeps the button at the top. That helps reachability on small screens.

Performance matters too. Sidebar data remains static, so keep it small. Avoid client data fetching inside the sidebar. The current solution reads in memory and renders instantly.

SEO Notes for App Router Dashboards

Dashboards are usually private. Still, internal findability helps teams. Use clear labels for sections and pages. Friendly names make searching within your product easier.

For public docs, add structured routing and consistent headings. Descriptive titles improve discoverability. App Router metadata supports per-page titles and descriptions. Use them where appropriate.

HeroUI components render accessible HTML by default. That improves baseline quality. Reasonable markup benefits assistive technologies and automated tools.

Extending the Pattern

Eventually, you will want server data in the sidebar. For example, counts beside items or permissions per role. Move the NAV_SECTIONS array behind a small service and cache responses. You can still render the Accordion on the client while fetching counts in parallel on the server.

Theming also matters. HeroUI supports tokens and theming through the provider. You can switch to dark mode without changing the component code. Consistent tokens maintain tidy typography and spacing.

Conclusion: consider analytics. Track sidebar link clicks with a lightweight handler. Events reveal which features users open most. That insight informs future navigation changes.

Finally

You now have a collapsible dashboard sidebar built with HeroUI and the Next.js App Router. It collapses neatly, highlights active routes, and supports mobile gracefully. The architecture scales with sections and nested segments. Styling remains clean and accessible.

Keep iterating as your app grows. Add role-based items, server data, and theming. This foundation will support it all.

This article was originally published on Medium.

Leave a Comment

Your email address will not be published. Required fields are marked *