Navigation
Configure the Next.js navbar, sidebar, user menu, and section tabs from typed config, with role, org-role, and feature-flag gating plus i18n labels.
Navigation is data-driven config, not hardcoded markup. Per-app menus live in apps/web-next/config/; the shared sectionTabsConfig (packages/config/src/section-tabs.ts) drives the sub-tabs inside /admin, /settings, and /settings/organization.
Where each menu lives
Each menu is a typed array you edit directly - add, reorder, or remove items without touching components.
| Menu | File | Export | Renders |
|---|---|---|---|
| Marketing navbar | apps/web-next/config/navbar.ts | navbarItems | top bar on public pages |
| Dashboard sidebar | apps/web-next/config/sidebar.ts | sidebarConfig | authenticated app shell |
| User menu | apps/web-next/config/user-menu.ts | userMenuItems | avatar dropdown |
| Section tabs | packages/config/src/section-tabs.ts | sectionTabsConfig | admin / settings tab strip |
Section tabs are shared because the admin and settings sections are identical across frontends; the surrounding chrome (navbar/sidebar/user menu) is per-app. Each app types its config with its own icon type - web-next passes the Phosphor Icon type (SidebarConfig<Icon>).
Anatomy of a nav item
Each item carries a label, icon, destination, active-match rule, and optional gating fields. The sidebar groups items into categories, and a whole category can be gated too.
// apps/web-next/config/sidebar.ts
import { GaugeIcon, ShieldChevronIcon } from "@phosphor-icons/react/ssr";
export const sidebarConfig: SidebarConfig<Icon> = {
navMain: [
{ title: "sidebar.dashboard", url: "/dashboard", icon: GaugeIcon, match: "exact" },
],
categories: [
{
title: "sidebar.categories.admin",
roles: ["admin"], // gate the whole group
items: [
// activeMatch keeps the item highlighted across every /admin/* child route
{ title: "sidebar.admin", url: "/admin/users", icon: ShieldChevronIcon, activeMatch: "/admin", roles: ["admin"] },
],
},
],
navSecondary: [],
};Active-state matching is per item:
| Field | Effect |
|---|---|
match: "exact" | active only when the path equals url |
match: "startsWith" | active when the path starts with url (the default) |
activeMatch | path/prefix used for highlighting instead of url - keep a parent item lit across its children (e.g. activeMatch: "/settings" for a link to /settings/profile) |
Two conventions keep config self-contained:
- Labels are i18n keys: strings like
"sidebar.dashboard"render viatDynamic(t, item.title)wheretcomes fromuseTranslations()(next-intl), so every locale stays in sync. - Icons are imported directly: React Phosphor
*Iconcomponents from@phosphor-icons/react/ssr(e.g.GaugeIcon), never a string-key registry, so config never depends on a separate icon map.
Gating fields
Add any of these to an item to control visibility. Omit them all and the item is always shown.
| Field | Type | Shows item when |
|---|---|---|
roles | UserRole[] - "user" | "admin" | user has one of these app roles |
orgRoles | OrgRole[] - "member" | "admin" | "owner" | member has one of these organization roles (resolved from the active org's members) |
requires | SidebarFeatureFlag[] | every named feature is enabled |
The SidebarFeatureFlag type accepts multiTenant, notifications, apiKeys, credits, and adminNotifications - but only the first four are resolved by the runtime check (isFeatureEnabled maps them to config.tenancy.multiTenant, config.notifications.enabled, config.apiKeys.enabled, and config.payment.enabled && pricingConfig.credits.enabled). adminNotifications falls through to the true default, so it never hides anything - don't rely on it as a gate.
Gating is declarative: items that fail a check are hidden, not disabled. A category that ends up with zero visible items is dropped entirely. Treat menu visibility as UX only - pair roles/orgRoles with server-side enforcement.
The marketing navbar uses a different switch: each
navbarItemsentry has anenabled?: booleanand is filtered byenabled !== false(noroles/requires). The default config drives the blog and docs links fromconfig.blog.enabledandconfig.docs.enabled. Navbar items can also nestsubitems(dropdown), lay that dropdown out as a multi-column grid withcolumns(1 | 2 | 3 | 4, default1), and setprimary: trueto appear in the mobile bottom nav. A dropdown parent may omithrefto become a pure trigger that only opens its submenu instead of navigating. Mark a linkexternalfor hard navigation to a URL served outside the app; external links open in a new tab by default, so setnewTab: falseto keep one in the same tab.
Feature-flag visibility
Items behind a requires flag disappear when the matching feature is off, keeping menus honest per build.
| Item | Location | Gate | Hidden when |
|---|---|---|---|
| Organization (sidebar link) | config/sidebar.ts | requires: ["multiTenant"] | config.tenancy.multiTenant is false |
| API keys (admin tab) | section-tabs.ts admin | requires: ["apiKeys"] | config.apiKeys.enabled is false |
| Developers (settings tab) | section-tabs.ts settings | requires: ["apiKeys"] | config.apiKeys.enabled is false |
| Organizations (admin tab) | section-tabs.ts admin | requires: ["multiTenant"] | config.tenancy.multiTenant is false |
| Admin (sidebar category + link) | config/sidebar.ts | roles: ["admin"] | viewer is not an admin |
| Activity (organization tab) | section-tabs.ts organization | orgRoles: ["owner", "admin"] | viewer is a plain member |
Frequently asked questions
Why are labels i18n keys instead of plain text?
So one config drives every locale. The key resolves at render via next-intl; add the matching string under your en messages and other locales follow.
How do I add a new sidebar link?
Import its Phosphor *Icon from @phosphor-icons/react/ssr, then append an item with title, url, and icon to sidebarConfig in apps/web-next/config/sidebar.ts. Add gating fields only if it should be restricted.
How do I show a navbar dropdown in multiple columns?
Set columns on the navbar item (1 | 2 | 3 | 4, default 1). Its subitems then lay out as that many columns on desktop, filling left to right; the mobile menu stays single-column. Four subitems with columns: 2 render as a 2x2 grid:
// apps/web-next/config/navbar.ts
{ title: "nav.product", href: "/#features", icon: StackSimpleIcon, columns: 2,
subitems: [ /* 4 items render as a 2x2 grid */ ] }Why isn't there a shared icon-name registry? Config is intentionally self-contained - importing the icon component directly avoids a hidden string-to-component map, so each frontend's config stands on its own.
Does hiding a menu item secure the route? No. Gating fields only control what renders. Enforce permissions on the server with the authorization helpers.