GenerateSaaS

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.

MenuFileExportRenders
Marketing navbarconfig/navbar.tsnavbarItemsTop bar on public pages
Sidebar layoutpackages/config/src/sidebar-layout.tsSIDEBAR_LAYOUT, composeSidebarConfigWhere each sidebar row sits, for web and desktop
Dashboard sidebarconfig/sidebar.tsWEB_SIDEBAR_ROWS, sidebarConfigAuthenticated app shell
User menuconfig/user-menu.tsuserMenuItemsAvatar dropdown
Settings railpackages/config/src/settings-nav.tssettingsNavGroupsThe grouped rail inside the settings hub
Section tabspackages/config/src/section-tabs.tssectionTabsConfigThe 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:

MenuHolds
Sidebar - primary rows (no heading)Dashboard, plus the AI surfaces (Chat, Automations) where enabled
Sidebar - AdminAdmins only - one row over the admin tab strip
Sidebar footerSettings row, Search, language / theme / notifications, Credits, user menu
User menuProfile, 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 navSecondary and rendered flat. It opens /settings, the hub's own URL, which no build can gate off; the rail behind it carries every /settings destination. 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.

ToEdit
Change a row's link, icon, gate or match ruleThat 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 categorySIDEBAR_LAYOUT (main, categories, secondary)
Add a rowAdd 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 categoryAdd 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);
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.
  • 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 requires credits, and the resolver moves it to the section's first enabled tab instead. It runs over categories and navSecondary in the sidebar and over the user menu, so an umbrella of your own keeps its gating wherever you put it; navMain is 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.

FieldOnEffect
itemsAn itemNested entries, rendered as a collapsible sub-menu. Gated by the same roles / orgRoles / requires fields
defaultOpenAn item with itemsWhether the group starts expanded on a first visit. Defaults to false
urlAn item with itemsNot allowed - a group only opens; a url on it is a type error
heading: falseA categoryDrops the uppercase heading, for a category whose single group row is the heading
expandA 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 items type-check but the sidebar renders one level of nesting.
  • The group holding the current page always opens, whatever defaultOpen says, 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.

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

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 either config.ai.byok or config.ai.builtin
desktopTasksconfig.desktop.agents.enabled AND at least one aiTasks entry targeting the desktop - the same condition the Tasks route guard reads
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.

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"] }

On this page