jonsully1.dev

Building a Role-Based Sidebar in Next.js

Cover Image for Building a Role-Based Sidebar in Next.js
Photo by   on 
John O'Sullivan
John O'Sullivan
Senior Full Stack Engineer
& DevOps Practitioner

📖 10 minute read Component Isolation, Server/Client Separation, and 100% Test Coverage

Introduction

In this article, we walk through the process of building a role-based sidebar component for a Next.js application. We follow senior-level engineering practices: component isolation, separation of concerns, pure functions for business logic, and comprehensive unit testing with Vitest. By the end, we achieve 100% test coverage across statements, branches, functions, and lines.

The Goal

Build a sidebar component that:

  • Displays navigation items based on the authenticated user's role.
  • Is reusable, testable, and scalable.
  • Follows the Next.js App Router's server/client component model.
  • Achieves 100% unit test coverage.

Architecture Overview

We split the sidebar into four files, each with a single responsibility:

src/components/common/sidebar/
  sidebarItems.ts       # Data: the canonical list of sidebar items
  sidebarUtils.ts       # Logic: pure function to filter items by role
  SidebarServer.tsx     # Server component: computes visible items
  SidebarClient.tsx     # Client component: renders UI and manages state

This structure follows the Single Responsibility Principle and Separation of Concerns — two foundational software engineering practices.

Step 1: Define the Data

We start with a single source of truth for sidebar items. Each item can optionally specify which roles are allowed to see it.

// sidebarItems.ts
export interface SidebarItem {
  key: string;
  label: string;
  onlyFor?: string[];
}

export const sidebarItems: SidebarItem[] = [
  { key: "dashboard", label: "Dashboard" },
  { key: "projects", label: "Projects", onlyFor: ["admin", "developer"] },
  { key: "users", label: "Users", onlyFor: ["admin"] },
];

By keeping this in its own file, we avoid duplicating the array across components and tests.

Step 2: Extract Business Logic into a Pure Function

Role-based filtering is business logic. Rather than embedding it in a React component (which is hard to test in isolation), we extract it into a pure function.

// sidebarUtils.ts
import { SidebarItem } from "./sidebarItems";

export function filterSidebarItems(
  items: SidebarItem[],
  role: string,
): SidebarItem[] {
  return items.filter(
    (item) => !item.onlyFor || item.onlyFor.includes(role),
  );
}

A pure function has no side effects and always returns the same output for the same input. This makes it trivial to test.

Step 3: Build the Server Component

The server component is responsible for preparing data. It filters the sidebar items based on the user's role and passes the result to the client component.

// SidebarServer.tsx
import SidebarClient from "./SidebarClient";
import { filterSidebarItems } from "./sidebarUtils";
import { sidebarItems } from "./sidebarItems";
import { Role } from "@hive/shared/src/platform/roles";

export default function SidebarServer({
  role,
  brand = "Hive",
  initialKey,
}: {
  role: Role;
  brand?: string;
  initialKey?: string;
}) {
  const visibleItems = filterSidebarItems(sidebarItems, role);
  return (
    <SidebarClient
      items={visibleItems}
      brand={brand}
      initialKey={initialKey}
    />
  );
}

Why a server component? It runs on the server, so the client never sees items the user isn't allowed to access. This is more secure and performant than filtering on the client.

Step 4: Build the Client Component

The client component is intentionally simple. It receives pre-filtered items and manages only UI state (which item is active).

// SidebarClient.tsx
"use client";

import { useState } from "react";
import { SidebarItem } from "./sidebarItems";

export default function SidebarClient({
  items,
  brand = "Hive",
  initialKey,
}: {
  items: SidebarItem[];
  brand?: string;
  initialKey?: string;
}) {
  const [activeKey, setActiveKey] = useState(
    initialKey ?? items[0]?.key,
  );

  return (
    <aside className="rh-sidebar">
      <p className="rh-sidebar__brand">{brand}</p>
      <nav>
        {items.map((item) => (
          <button
            key={item.key}
            className={`rh-sidebar__item${
              item.key === activeKey ? " rh-sidebar__item--active" : ""
            }`}
            onClick={() => setActiveKey(item.key)}
            aria-current={item.key === activeKey ? "page" : undefined}
          >
            {item.label}
          </button>
        ))}
      </nav>
    </aside>
  );
}

This component is uncontrolled — it manages its own active state internally. The initialKey prop allows the parent to set the default active item (useful for URL-based routing and deep linking).

Step 5: Provide User Context

We use React Context to make the user's role available to any component in the tree.

// UserContext.tsx
"use client";

import { createContext, useContext } from "react";
import { Role } from "@hive/shared/src/platform/roles";

export interface UserContextType {
  role: Role;
}

export const UserContext = createContext<UserContextType | undefined>(
  undefined,
);

export function useUser() {
  const ctx = useContext(UserContext);
  if (!ctx)
    throw new Error("useUser must be used within UserProvider");
  return ctx;
}

export function UserProvider({
  user,
  children,
}: {
  user: UserContextType;
  children: React.ReactNode;
}) {
  return (
    <UserContext.Provider value={user}>
      {children}
    </UserContext.Provider>
  );
}

The UserProvider wraps the app in layout.tsx, fetching the user profile from the backend and passing it down.

Step 6: Test Everything

Testing the Pure Function

Pure functions are the easiest to test. We verify filtering for every role.

// sidebarUtils.test.ts
import { filterSidebarItems } from "./sidebarUtils";

const items = [
  { key: "dashboard", label: "Dashboard" },
  { key: "projects", label: "Projects", onlyFor: ["admin", "developer"] },
  { key: "users", label: "Users", onlyFor: ["admin"] },
];

describe("filterSidebarItems", () => {
  it("returns all items for admin", () => {
    expect(filterSidebarItems(items, "admin").map((i) => i.key))
      .toEqual(["dashboard", "projects", "users"]);
  });

  it("returns correct items for developer", () => {
    expect(filterSidebarItems(items, "developer").map((i) => i.key))
      .toEqual(["dashboard", "projects"]);
  });

  it("returns only dashboard for viewer", () => {
    expect(filterSidebarItems(items, "viewer").map((i) => i.key))
      .toEqual(["dashboard"]);
  });
});

Testing the Client Component

We wrap the component in a UserContext.Provider to simulate the context, and test all UI behaviors and edge cases.

// SidebarClient.test.tsx
import { render, screen, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import SidebarClient from "./SidebarClient";
import { UserContext } from "@/context/UserContext";

const sidebarItems = [
  { key: "dashboard", label: "Dashboard" },
  { key: "projects", label: "Projects", onlyFor: ["admin", "developer"] },
  { key: "users", label: "Users", onlyFor: ["admin"] },
];

function renderWithUser(role = "admin", initialKey = "dashboard") {
  return render(
    <UserContext.Provider value={{ role }}>
      <SidebarClient
        brand="Hive"
        initialKey={initialKey}
        items={sidebarItems}
      />
    </UserContext.Provider>,
  );
}

afterEach(() => cleanup());

describe("Sidebar", () => {
  it("defaults to first item when initialKey is not provided", () => {
    render(
      <UserContext.Provider value={{ role: "admin" }}>
        <SidebarClient brand="Hive" items={sidebarItems} />
      </UserContext.Provider>,
    );
    expect(screen.getByText("Dashboard").className)
      .toContain("rh-sidebar__item--active");
  });

  it("renders the brand text", () => {
    renderWithUser();
    expect(screen.getByText("Hive")).toBeInTheDocument();
  });

  it("renders custom brand text", () => {
    render(
      <UserContext.Provider value={{ role: "admin" }}>
        <SidebarClient brand="My App" initialKey="dashboard" items={sidebarItems} />
      </UserContext.Provider>,
    );
    expect(screen.getByText("My App")).toBeInTheDocument();
  });

  it("renders all menu items", () => {
    renderWithUser("admin");
    expect(screen.getByText("Dashboard")).toBeInTheDocument();
    expect(screen.getByText("Projects")).toBeInTheDocument();
    expect(screen.getByText("Users")).toBeInTheDocument();
  });

  it("marks the active item with aria-current", () => {
    renderWithUser("admin");
    expect(screen.getByText("Dashboard")).toHaveAttribute("aria-current", "page");
  });

  it("applies the active class", () => {
    renderWithUser("admin");
    expect(screen.getByText("Dashboard").className)
      .toContain("rh-sidebar__item--active");
  });

  it("changes active item when clicked", async () => {
    renderWithUser("admin");
    const user = userEvent.setup();
    await user.click(screen.getByText("Projects"));
    expect(screen.getByText("Projects").className)
      .toContain("rh-sidebar__item--active");
  });
});

Testing the Context

We test that the context provides data correctly and throws when used outside the provider.

// UserContext.test.tsx
function TestComponent() {
  const { role } = useUser();
  return <div>{role}</div>;
}

describe("UserContext", () => {
  it("provides the user role to children", () => {
    render(
      <UserProvider user={{ role: "admin" }}>
        <TestComponent />
      </UserProvider>,
    );
    expect(screen.getByText("admin")).toBeInTheDocument();
  });

  it("throws error if useUser is called outside provider", () => {
    console.error = () => {};
    expect(() => render(<TestComponent />)).toThrow(
      /useUser must be used within UserProvider/,
    );
  });
});

The Result: 100% Coverage

---------------------------|---------|----------|---------|---------
File                       | % Stmts | % Branch | % Funcs | % Lines
---------------------------|---------|----------|---------|---------
All files                  |     100 |      100 |     100 |     100
 SidebarClient.tsx         |     100 |      100 |     100 |     100
 sidebarUtils.ts           |     100 |      100 |     100 |     100
 UserContext.tsx            |     100 |      100 |     100 |     100
---------------------------|---------|----------|---------|---------

Key Takeaways

  1. Isolate components — each file has one job.
  2. Extract business logic into pure functions — easy to test, easy to reuse.
  3. Separate server and client components — leverage Next.js for performance and security.
  4. Use React Context for cross-cutting concerns like user roles.
  5. Keep a single source of truth for shared data (like sidebar items).
  6. Test every branch — including edge cases like empty arrays and missing props.
  7. Set coverage thresholds to enforce quality standards in CI.

This process is repeatable for every new feature. Build the data layer, extract the logic, compose the UI, and test each layer independently.