GenerateSaaS

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.

MenuFileExportRenders
Marketing navbarconfig/navbar.tsnavbarItemsTop bar on public pages
Dashboard sidebarconfig/sidebar.tssidebarConfigAuthenticated app shell
User menuconfig/user-menu.tsuserMenuItemsAvatar dropdown
Section tabspackages/config/src/section-tabs.tssectionTabsConfigAdmin / 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:

MenuHolds
Sidebar - Workspace groupDashboard / Home, plus the AI surfaces (Chat, Automations) where enabled
Sidebar - Manage categoryOrganization, Project - the workspace objects
User menuProfile, 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: [],
};
FieldEffect
match: "startsWith"Active when the path starts with url (the default)
match: "exact"Active only when the path equals url
activeMatchPath or prefix used for highlighting instead of url - keeps a parent lit across its children
  • Labels are i18n keys: "sidebar.dashboard" renders via tDynamic(t, item.title) with t from useTranslations(). Define the key in packages/i18n/translations/en/web.json.
  • Icons are imported directly: *Icon components 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 configured url, 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.

FieldTypeShows item when
rolesUserRole[] - "user" | "admin"User has one of these app roles
orgRolesOrgRole[] - "member" | "admin" | "owner"Member has one of these organization roles in the active org
requiresSidebarFeatureFlag[]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.

FlagEnabled when
multiTenantconfig.tenancy.multiTenant
projectsconfig.projects.enabled - see projects
notificationsconfig.notifications.enabled
apiKeysconfig.apiKeys.enabled
creditsconfig.payment.enabled and pricingConfig.credits.enabled
agentsconfig.desktop.agents.enabled - gates the desktop app's Chat and Automations, see desktop AI
aiconfig.ai.enabled
aiByokconfig.ai.enabled and config.ai.byok
aiModelsconfig.ai.enabled and any of config.ai.byok, config.ai.builtin, or config.runner.enabled
runnerconfig.runner.enabled
mcpServerconfig.mcpServer.enabled
emailTrackingconfig.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.

FieldEffect
subitemsRenders the item as a dropdown
sectionDrops the item when that content section has nothing published for the locale - see Content sections
columnsLays the dropdown out as 1 | 2 | 3 | 4 desktop columns (default 1), filling left to right; mobile stays single-column
primary: trueAlso surfaces the item in the mobile bottom nav
href omittedDropdown parent becomes a pure trigger that opens its submenu instead of navigating
externalHard 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 section drops the same way: a compare section with product files but no publishable pair serves nothing, so its link goes too.
  • Subitems inherit the parent's match, so one match: "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"] }

On this page