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)
  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

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