Straktur
Recipes

Customize Navigation

How to add, reorder, and nest sidebar items — icons, sub-items, translations

Customize Navigation

TL;DR: Edit one file (src/lib/config/navigation.ts), add translations, done. Sub-items with query params give you filtered views of the same list page.

When You Need This

  • You added a new feature and want it in the sidebar
  • You want filtered sub-views (e.g., "Active Clients", "Lead Clients" under "Clients")
  • You want to reorder or group navigation items
  • You want colored dot indicators next to items

The One File That Matters

src/lib/config/navigation.ts

Everything starts here. The sidebar, breadcrumbs, and command palette all read from this config.

type NavItem = {
  label: string           // Translation key (layout.sidebar.nav.{label})
  href: string            // Route path
  icon?: ComponentType    // Lucide icon
  dotColor?: string       // Colored dot indicator (e.g., "bg-green-500")
  match?: "exact" | "prefix"  // Active state matching (default: "prefix")
  isExample?: boolean     // Marks as example (shows badge in dev mode)
  minRole?: Role          // Hide from users below this role (e.g., "admin")
  children?: NavItem[]    // Sub-items
}

Step 1: Add a Simple Navigation Item

// src/lib/config/navigation.ts
import { Package } from "lucide-react"

const yourNavItems: NavItem[] = [
  {
    label: "orders",
    href: "/orders",
    icon: Package,
  },
]

Then include it in getMainNavItems():

export function getMainNavItems(): NavItem[] {
  return [
    ...coreNavItems,     // Dashboard
    ...yourNavItems,     // ← Your items
    ...adminNavItems,    // Users, Settings
  ]
}

Step 2: Add Translation

Add the label to both language files:

// messages/en.json
{
  "layout": {
    "sidebar": {
      "nav": {
        "orders": "Orders"
      }
    }
  }
}
// messages/pl.json
{
  "layout": {
    "sidebar": {
      "nav": {
        "orders": "Zamówienia"
      }
    }
  }
}

The sidebar renders labels using t("nav.orders") from the layout.sidebar namespace. Breadcrumbs use the same translations automatically.


Adding Sub-Items (Filtered Views)

Sub-items appear as a collapsible section under the parent. They typically link to the same list page with different query parameters — giving users quick access to filtered views.

{
  label: "orders",
  href: "/orders",
  icon: Package,
  children: [
    {
      label: "orders_pending",
      href: "/orders?statusCode=pending",
      match: "exact",              // ← Required for query param matching
      dotColor: "bg-yellow-500",   // ← Colored dot indicator
    },
    {
      label: "orders_processing",
      href: "/orders?statusCode=processing",
      match: "exact",
      dotColor: "bg-blue-500",
    },
    {
      label: "orders_completed",
      href: "/orders?statusCode=completed",
      match: "exact",
      dotColor: "bg-green-500",
    },
  ],
}

Two things to remember:

  1. match: "exact" — without this, every child would highlight when you're on /orders because the default "prefix" matching would match all of them
  2. Translations — each child needs its own key:
{
  "layout": {
    "sidebar": {
      "nav": {
        "orders": "Orders",
        "orders_pending": "Pending",
        "orders_processing": "Processing",
        "orders_completed": "Completed"
      }
    }
  }
}

How the Page Reads the Filter

Your list page picks up the query parameter automatically:

// src/app/(dashboard)/orders/page.tsx
const { filters } = useDataTableUrlState({
  tableId: "orders",
  filterKeys: ["statusCode"],
})

const { data } = useQuery(
  orpcUtils.orders.list.queryOptions({
    input: {
      page,
      pageSize,
      ...(filters.statusCode.length > 0 && {
        statusCode: filters.statusCode,
      }),
    },
  })
)

Click "Pending" in the sidebar → URL becomes /orders?statusCode=pending → DataTable shows only pending orders → sidebar highlights "Pending" sub-item.


Colored Dot Indicators

The dotColor property adds a small colored dot before the label. Use any Tailwind background class:

{
  label: "orders_urgent",
  href: "/orders?priority=urgent",
  match: "exact",
  dotColor: "bg-red-500",     // Red dot
}

This works on both parent and child items. Common colors:

ColorClassUse case
Greenbg-green-500Active, completed
Yellowbg-yellow-500Pending, warning
Bluebg-blue-500In progress
Redbg-red-500Urgent, overdue
Slatebg-slate-400Draft, inactive

Active State: How It Works

The sidebar highlights the current page automatically:

  • match: "prefix" (default) — /orders matches /orders, /orders/123, /orders/123/edit
  • match: "exact"/orders?statusCode=pending only matches that exact path + query params

When to use which:

  • Parent items → "prefix" (default, don't set anything)
  • Sub-items with query params → "exact"
  • Items that share a path prefix → "exact" to avoid double-highlighting

Role-Based Visibility (minRole)

Hide nav items from users below a given role. Useful for admin-only sections like Settings or Users.

{
  label: "settings",
  href: "/settings",
  icon: Settings,
  minRole: "admin",  // owner + admin see it; member + viewer don't
}

Roles are ranked: owner (100) > admin (80) > member (50) > viewer (10). A user sees any item where their role meets minRole or higher.

Wire it through

getMainNavItems(role?) does the filtering — pass the current user's role when calling it:

// In a client component
import { useSessionRole } from "@/components/providers/session-provider"

const role = useSessionRole()
const items = getMainNavItems(role)

Without a role argument, all items are returned (backward-compatible with code that hasn't been updated yet).

The sidebar and command palette both already apply this filter — add minRole to a nav item and it's hidden from both at once. Before minRole existed, the command palette could surface pages users couldn't actually access.

minRole only hides the nav link. It is not a route guard — always enforce permissions server-side in your oRPC routers and in src/proxy.ts redirects.


Collapsed Sidebar Behavior

When the sidebar is collapsed (icon-only mode), sub-items appear in a hover popover:

  • Parent shows as an icon
  • Hovering opens a popup with the parent label and all children
  • Children keep their dot indicators and labels in the popover
  • Click any item to navigate

This works automatically — no extra configuration needed.


Icons

Import icons from lucide-react. Some commonly used ones:

import {
  Building2,       // Companies, organizations
  CalendarCheck,   // Events, activities, schedules
  Contact,         // People, contacts
  FileText,        // Documents, files
  LayoutDashboard, // Dashboard
  Package,         // Orders, products
  Receipt,         // Invoices
  Settings,        // Settings
  Users,           // Team, users
  FolderKanban,    // Projects
  ListTodo,        // Tasks
  BarChart3,       // Reports, analytics
} from "lucide-react"

Browse all icons at lucide.dev/icons.


Breadcrumbs are generated automatically from the URL path. They use the same translations as the sidebar:

/orders/abc-123 → Orders > abc-12...
  • Path segments matching a nav item label → translated name
  • UUID-like segments → truncated (abc-12...)
  • Other segments → converted from kebab-case to Title Case

No extra configuration needed. If you added the nav item and its translation, breadcrumbs just work.


Complete Example: Adding "Orders" with Sub-Items

1. Navigation config

// src/lib/config/navigation.ts
import { Package } from "lucide-react"

const businessNavItems: NavItem[] = [
  {
    label: "orders",
    href: "/orders",
    icon: Package,
    children: [
      {
        label: "orders_pending",
        href: "/orders?statusCode=pending",
        match: "exact",
        dotColor: "bg-yellow-500",
      },
      {
        label: "orders_shipped",
        href: "/orders?statusCode=shipped",
        match: "exact",
        dotColor: "bg-blue-500",
      },
      {
        label: "orders_delivered",
        href: "/orders?statusCode=delivered",
        match: "exact",
        dotColor: "bg-green-500",
      },
    ],
  },
]

export function getMainNavItems(): NavItem[] {
  return [
    ...coreNavItems,
    ...businessNavItems,
    ...(shouldShowExamplesInMenu() ? exampleNavItems : []),
    ...adminNavItems,
  ]
}

2. Translations

// messages/en.json — add to layout.sidebar.nav
{
  "orders": "Orders",
  "orders_pending": "Pending",
  "orders_shipped": "Shipped",
  "orders_delivered": "Delivered"
}
// messages/pl.json — add to layout.sidebar.nav
{
  "orders": "Zamówienia",
  "orders_pending": "Oczekujące",
  "orders_shipped": "Wysłane",
  "orders_delivered": "Dostarczone"
}

3. Verify

npm run typecheck

Common Mistakes

Don't forget match: "exact" on sub-items:

// ❌ Wrong — all children highlight on /orders
{ label: "orders_pending", href: "/orders?statusCode=pending" }

// ✅ Correct — only highlights on exact match
{ label: "orders_pending", href: "/orders?statusCode=pending", match: "exact" }

Don't import getMainNavItems dynamically in components:

// ❌ Wrong — can bundle all icons into the client
import { getMainNavItems } from "@/lib/config/navigation"

// ✅ Correct — import explicitly where needed
import { getMainNavItems } from "@/lib/config/navigation"
// This is fine in layout.tsx or app-sidebar.tsx which already import it

Don't skip translations:

// ❌ Wrong — shows raw key "orders_pending" in sidebar
{ label: "orders_pending", href: "/orders?statusCode=pending" }
// Without messages/en.json entry → renders "orders_pending"

// ✅ Correct — add to both en.json and pl.json
// messages/en.json: "orders_pending": "Pending"

On this page