Navigation
Configure the navbar, sidebar, user menu, and the settings rail 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 SIDEBAR_LAYOUT decides where every sidebar row sits in both the web and desktop apps, settingsNavGroups drives the vertical rail across /settings/*, and sectionTabsConfig drives the /admin tab strip beside it.
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 |
| Sidebar layout | packages/config/src/sidebar-layout.ts | SIDEBAR_LAYOUT, composeSidebarConfig | Where each sidebar row sits, for web and desktop |
| Dashboard sidebar | config/sidebar.ts | WEB_SIDEBAR_ROWS, sidebarConfig | Authenticated app shell |
| User menu | config/user-menu.ts | userMenuItems | Avatar dropdown |
| Settings rail | packages/config/src/settings-nav.ts | settingsNavGroups | The grouped rail inside the settings hub |
| Section tabs | packages/config/src/section-tabs.ts | sectionTabsConfig | The admin tab strip |
The three packages/config entries are shared because they are identical across frontends; each shell's rows and chrome are per-app, typed with the Phosphor Icon type (SidebarConfig<Icon>).
One page belongs to one navigation. Everything under /settings is the rail - account, product settings, organization, project - and the Settings row lights on all of it. Organization and Project have no sidebar rows of their own; the workspace switcher at the top of the sidebar picks them. The admin panel lives outside /settings and keeps its tab strip.
The sidebar and the user menu split by SUBJECT, and the web and desktop apps ship the same split:
| Menu | Holds |
|---|---|
| Sidebar - primary rows (no heading) | Dashboard, plus the AI surfaces (Chat, Automations) where enabled |
| Sidebar - Admin | Admins only - one row over the admin tab strip |
| Sidebar footer | Settings row, Search, language / theme / notifications, Credits, user menu |
| User menu | Profile, Billing, Security, Go Home, Log out - shortcuts into the account run, and the way out |
- The footer reads top to bottom in that order in both apps, and the primary rows carry no heading.
- Settings is ONE nav row over ONE hub, declared in
navSecondaryand rendered flat. It opens/settings, the hub's own URL, which no build can gate off; the rail behind it carries every/settingsdestination. The user menu keeps three shortcuts into the account run, never a second Settings entry - the command palette de-duplicates by URL, so two rows on one destination would silently become one. - The notifications bell closes the controls row, right of the theme switcher.
- Go Home leaves the dashboard for the marketing site, directly above Log out. Web only - the desktop app has no marketing site.
The command palette (Cmd/Ctrl+K, or the Search row in the sidebar footer) indexes both lists under the same gates, plus the settings hub's Profile, Billing, Security, Organization and Project pages (gated like the settings rail), so an item you add to either is searchable and one your build hides is not. The desktop palette offers the same destinations.
The shared sidebar layout
Both apps build sidebarConfig through composeSidebarConfig from @repo/config/sidebar-layout, so the two sidebars cannot drift. The layout names WHERE each slot sits; each shell supplies its own row per slot - title key, route, icon, gates.
| To | Edit |
|---|---|
| Change a row's link, icon, gate or match rule | That shell's rows: WEB_SIDEBAR_ROWS in apps/web/config/sidebar.ts, DESKTOP_SIDEBAR_ROWS in apps/desktop/src/renderer/config/sidebar.ts |
| Reorder rows, or move one into another category | SIDEBAR_LAYOUT (main, categories, secondary) |
| Add a row | Add the slot to SidebarSlot, place it in SIDEBAR_LAYOUT, then give it a row in both shells - TypeScript fails the shell that has none |
| Add a category | Add its id to SidebarCategoryId and SIDEBAR_LAYOUT.categories, then a title key in each shell's categoryTitles |
Anatomy of a nav item
Each row carries a label, an icon, a destination, an active-match rule, and optional gating fields. The layout groups rows into categories, and a whole category can be gated (roles, heading on the layout entry).
// apps/web/config/sidebar.ts
import { GaugeIcon, GearIcon, ShieldChevronIcon } from "@phosphor-icons/react/ssr";
import { composeSidebarConfig } from "@repo/config/sidebar-layout";
export const WEB_SIDEBAR_ROWS: SidebarShellRows<Icon> = {
items: {
dashboard: { title: "sidebar.dashboard", url: "/dashboard", icon: GaugeIcon, match: "exact" },
chat: { title: "sidebar.chat", url: "/chat", icon: ChatCircleIcon, requires: ["ai"] },
automations: { /* … */ },
// Illustrative: the shipped Admin entry is a plain link over its tab strip. A group has no
// `url` - the whole row toggles it, and the section's pages are the children.
admin: { title: "sidebar.admin", icon: ShieldChevronIcon, roles: ["admin"],
active: false, defaultOpen: false,
items: [{ title: "sidebar.admin_overview", url: "/admin", requires: ["credits"] },
{ title: "sidebar.users", url: "/admin/users" } /* … */] },
// The footer row. One Settings row over one hub; its activeMatch is DERIVED from
// `settingsNavGroups`, so it lights on exactly the pages the rail lists.
settings: { title: "sidebar.settings", url: "/settings", icon: GearIcon,
activeMatch: SETTINGS_ROUTES },
},
categoryTitles: { admin: "sidebar.categories.admin" },
};
export const sidebarConfig = composeSidebarConfig(WEB_SIDEBAR_ROWS);| 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. - Admin resolves its href at render through
resolveSectionUmbrella(lib/section-umbrella.ts), so it never lands on a page your build has gated off: its configured landing page is the finance dashboard, which requirescredits, and the resolver moves it to the section's first enabled tab instead. It runs overcategoriesandnavSecondaryin the sidebar and over the user menu, so an umbrella of your own keeps its gating wherever you put it;navMainis left alone, and so is a group, which navigates nowhere.
Collapsible groups
Give an item items and it becomes a group. A group never navigates: the whole row is the toggle, so on a phone there is no caret to hunt for, and the section's landing page is simply its first child. Both shipped sidebars are flat - the admin section is one row over its tab strip - and either gets this behaviour the moment you add items to an entry; the config example above nests the admin pages to show the shape.
| Field | On | Effect |
|---|---|---|
items | An item | Nested entries, rendered as a collapsible sub-menu. Gated by the same roles / orgRoles / requires fields |
defaultOpen | An item with items | Whether the group starts expanded on a first visit. Defaults to false |
url | An item with items | Not allowed - a group only opens; a url on it is a type error |
heading: false | A category | Drops the uppercase heading, for a category whose single group row is the heading |
expand | A shell's rows (WEB_SIDEBAR_ROWS) | "multiple" (default) leaves other groups alone; "single" closes the group's siblings when one opens |
- One drawn level. Deeper
itemstype-check but the sidebar renders one level of nesting. - The group holding the current page always opens, whatever
defaultOpensays, so a deep link never lands inside a collapsed group. The user can still close it. - A group lights up with its child. While a nested page is open, the parent row takes the active look too, so the section reads at a glance even on the collapsed rail; the child keeps its own highlight. A parent over a whole section still sets
active: false, so it never lights through a route of its own. - A group whose children are all gated off renders nothing.
- Children are text-only. The parent carries the icon; the guide line under it marks the nesting.
- On the collapsed icon rail the parent keeps its icon and tooltip; the caret and the sub-list drop out.
Toggles are REMEMBERED, per surface. The web writes a sidebar_groups cookie and the dashboard layout reads it server-side, so the first paint already has the right groups open - no layout shift, and never localStorage. The desktop renderer has no server, so it reads the same shape out of localStorage synchronously as the sidebar mounts. Both store only the groups the user actually toggled, so changing defaultOpen still reaches everyone else.
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 web sidebar, the settings rail, the admin tabs, and the per-page feature gates. The desktop passes its own resolution for ai, aiByok and aiModels, which fold in the web-only ai.webUi switch.
| 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 either config.ai.byok or config.ai.builtin |
desktopTasks | config.desktop.agents.enabled AND at least one aiTasks entry targeting the desktop - the same condition the Tasks route guard reads |
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.
One entry covers the whole settings hub: the bar names the SECTION and the rail names the destination, exactly as the bar names Admin over the admin pages.
// apps/web/config/sidebar.ts
export const extraTitleRoutes: NavMatchable[] = [
{ title: "sidebar.notifications", url: "/notifications", match: "startsWith" },
{ title: "sidebar.settings", url: "/settings", activeMatch: SETTINGS_ROUTES }
];Promote a settings destination to the sidebar
Every rail destination is a real route, so any of them can also appear as a sidebar row. Add a slot for it to the shared layout and give each shell a row whose url is that route; the same requires flag hides both when the feature is off.
A destination that gets its own sidebar row keeps its rail entry but must LEAVE the Settings row's activeMatch - filter its run out of SETTINGS_ROUTES in apps/web/config/sidebar.ts. Active matching picks the longest prefix, so two rows naming the same path tie and both light; an activeMatch on the new row cannot break that tie.
// apps/web/config/sidebar.ts - the row for a new `integrations` slot
import { PlugsConnectedIcon } from "@phosphor-icons/react/ssr";
integrations: { 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, the one flag-gated settings hub behind its rail, and the role-gated admin panel.