Navigation
Configure the navbar, sidebar, user menu, and section tabs from typed config files, 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/config/; the shared sectionTabsConfig 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 | config/navbar.ts | navbarItems | Top bar on public pages |
| Dashboard sidebar | config/sidebar.ts | sidebarConfig | Authenticated app shell |
| User menu | 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 those sections are identical across frontends; the surrounding chrome is per-app, typed with the Phosphor Icon type (SidebarConfig<Icon>).
The sidebar and the user menu split by SUBJECT, and the web and desktop apps ship the same split:
| Menu | Holds |
|---|---|
| Sidebar - Workspace group | Dashboard / Home, plus the AI surfaces (Chat, Automations) where enabled |
| Sidebar - Manage category | Organization, Project - the workspace objects |
| User menu | Profile, Billing, Security, Settings - everything about the signed-in person |
The command palette (Cmd/Ctrl+K, or the Search row in the sidebar footer) indexes both lists under the same gates, so an item you add to either is searchable and one your build hides is not.
Anatomy of a nav item
Each item carries a label, an icon, a destination, an active-match rule, and optional gating fields. The sidebar groups items into categories, and a whole category can be gated.
// apps/web/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: [
// The entry lands on the admin finance dashboard; activeMatch keeps it highlighted on
// every /admin/* child route the tabs lead to (users, organizations, billing logs, ...).
{ title: "sidebar.admin", url: "/admin", icon: ShieldChevronIcon, activeMatch: "/admin", roles: ["admin"] },
],
},
],
navSecondary: [],
};| Field | Effect |
|---|---|
match: "startsWith" | Active when the path starts with url (the default) |
match: "exact" | Active only when the path equals url |
activeMatch | Path or prefix used for highlighting instead of url - keeps a parent lit across its children |
- Labels are i18n keys:
"sidebar.dashboard"renders viatDynamic(t, item.title)withtfromuseTranslations(). Define the key inpackages/i18n/translations/en/web.json. - Icons are imported directly:
*Iconcomponents from@phosphor-icons/react/ssr, never a string-key registry. - Settings and Admin resolve their href at render through
resolveSectionUmbrella(lib/section-umbrella.ts), so neither lands on a tab your build has gated off - Admin falls back to its configuredurl, Settings drops when every settings tab is off. The sidebar and the user menu both run it, so moving either umbrella between them keeps its gating.
Gating fields
Add any of these to an item to control visibility. Omit them all and the item always shows.
| 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 in the active org |
requires | SidebarFeatureFlag[] | Every named feature is enabled |
Each flag resolves through isSidebarFeatureEnabled (@repo/config/sidebar-flags, re-exported at lib/sidebar-flags.ts) - one source of truth for the sidebar, the section tabs, and the per-page feature gates.
| Flag | Enabled when |
|---|---|
multiTenant | config.tenancy.multiTenant |
projects | config.projects.enabled - see projects |
notifications | config.notifications.enabled |
apiKeys | config.apiKeys.enabled |
credits | config.payment.enabled and pricingConfig.credits.enabled |
agents | config.desktop.agents.enabled - gates the desktop app's Chat and Automations, see desktop AI |
ai | config.ai.enabled |
aiByok | config.ai.enabled and config.ai.byok |
aiModels | config.ai.enabled and any of config.ai.byok, config.ai.builtin, or config.runner.enabled |
runner | config.runner.enabled |
mcpServer | config.mcpServer.enabled |
emailTracking | config.email.tracking.enabled |
Items that fail a check are hidden, not disabled, and a category left with zero visible items is dropped entirely.
Gating is UX only. Pair roles / orgRoles with server-side enforcement - see Authorization. A flag the resolver does not know defaults to enabled, so a typo in requires shows the item instead of hiding it.
Marketing navbar
navbarItems uses a different switch: each entry carries enabled?: boolean and is filtered on enabled !== false, with no roles or requires. The bar ships Product (a dropdown), Pricing, Blog and Docs, which binds to config.docs.enabled. The bottom-funnel pages are not in the bar: the footer's Company column carries Compare (/alternatives) and Comparisons (/compare), each gated on its section having something published.
| Field | Effect |
|---|---|
subitems | Renders the item as a dropdown |
section | Drops the item when that content section has nothing published for the locale - see Content sections |
columns | Lays the dropdown out as 1 | 2 | 3 | 4 desktop columns (default 1), filling left to right; mobile stays single-column |
primary: true | Also surfaces the item in the mobile bottom nav |
href omitted | Dropdown parent becomes a pure trigger that opens its submenu instead of navigating |
external | Hard navigation to a URL served outside the app; opens in a new tab unless you set newTab: false |
// apps/web/config/navbar.ts - 4 subitems render as a 2x2 grid
{ title: "nav.product", href: "/#features", icon: StackSimpleIcon, columns: 2,
subitems: [ /* … */ ] }- A dropdown whose subitems have all dropped goes with them, rather than opening an empty panel. A footer item carrying a
sectiondrops the same way: acomparesection with product files but no publishable pair serves nothing, so its link goes too. - Subitems inherit the parent's
match, so onematch: "startsWith"keeps a dropdown lit across a whole section - a post under/blog, a category listing under/blog/category.
Top bar title
The dashboard top bar names the active section, resolved from the same sidebar config the highlight uses - the most specific activeMatch / match wins. Routes with no sidebar entry take their title from extraTitleRoutes, exported alongside sidebarConfig; with no match at all, the bar falls back to the dashboard label.
The user menu's own pages are the main case: they have no sidebar entry, so they get their title here.
// apps/web/config/sidebar.ts
export const extraTitleRoutes: NavMatchable[] = [
{ title: "sidebar.notifications", url: "/notifications", match: "startsWith" },
{ title: "sidebar.billing", url: "/settings/billing" },
{ title: "sidebar.account", url: "/settings/profile",
activeMatch: ["/settings/profile", "/settings/security"] },
{ title: "sidebar.settings", url: "/settings/models",
activeMatch: ["/settings/models", "/settings/integrations", "/settings/runners",
"/settings/mcp", "/settings/developers"] }
];Promote a settings tab to the sidebar
Every settings tab is a real route, so any of them can also appear as a top-level sidebar item. Add one item to navMain whose url is the tab's route - the tab keeps rendering inside Settings too, and the same requires flag hides both when the feature is off.
// apps/web/config/sidebar.ts
import { PlugsConnectedIcon } from "@phosphor-icons/react/ssr";
{ title: "sidebar.integrations", url: "/settings/integrations",
icon: PlugsConnectedIcon, activeMatch: "/settings/integrations", requires: ["ai"] }Theming
Set the default color mode, the switcher, the selectable modes, and the decorative background from config.theme, and rebrand colors in the shared token sheet.
Dashboard and Settings
Build on the authenticated dashboard surface, its flag-gated account and settings tabs, and the role-gated admin panel.