GenerateSaaS

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.

MenuFileExportRenders
Marketing navbarapps/web-next/config/navbar.tsnavbarItemstop bar on public pages
Dashboard sidebarapps/web-next/config/sidebar.tssidebarConfigauthenticated app shell
User menuapps/web-next/config/user-menu.tsuserMenuItemsavatar dropdown
Section tabspackages/config/src/section-tabs.tssectionTabsConfigadmin / 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:

FieldEffect
match: "exact"active only when the path equals url
match: "startsWith"active when the path starts with url (the default)
activeMatchpath/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 via tDynamic(t, item.title) where t comes from useTranslations() (next-intl), so every locale stays in sync.
  • Icons are imported directly: React Phosphor *Icon components from @phosphor-icons/react/ssr (e.g. GaugeIcon), never a string-key registry, so config never depends on a separate icon map.

Top bar title

The dashboard top bar shows the active section's title, resolved from the same sidebar config the highlight uses - the most specific activeMatch/match wins, so the bar always names the section you are in. Routes with no sidebar entry (for example /notifications) take their title from extraTitleRoutes, a small NavMatchable[] exported alongside sidebarConfig; when nothing matches, the bar falls back to the dashboard label.

// apps/web-next/config/sidebar.ts
export const extraTitleRoutes: NavMatchable[] = [
  { title: "sidebar.notifications", url: "/notifications", match: "startsWith" }
];

Promote a settings tab to the sidebar

Each AI configuration surface lives in Settings but is a real route, so any tab can also appear as its own top-level sidebar item. Add one item to navMain whose url is the tab's route; the tab keeps rendering inside Settings as well, and the same requires flag keeps it hidden when the feature is off.

// apps/web-next/config/sidebar.ts
import { PlugsConnectedIcon } from "@phosphor-icons/react/ssr";

// Add to navMain - the tab still resolves inside Settings too.
{ title: "sidebar.integrations", url: "/settings/integrations",
  icon: PlugsConnectedIcon, activeMatch: "/settings/integrations", requires: ["ai"] }

Gating fields

Add any of these to an item to control visibility. Omit them all and the item is always shown.

FieldTypeShows item when
rolesUserRole[] - "user" | "admin"user has one of these app roles
orgRolesOrgRole[] - "member" | "admin" | "owner"member has one of these organization roles (resolved from the active org's members)
requiresSidebarFeatureFlag[]every named feature is enabled

The SidebarFeatureFlag type has eleven members, each resolved by the app's isSidebarFeatureEnabled helper (apps/web-next/lib/sidebar-flags.ts) - the single source of truth for the sidebar and section tabs. Nine map to a config check; the other two (adminNotifications and agents) have no case, so they fall through to the resolver's default and always pass - never rely on them as a gate. Any unhandled or future flag defaults to visible the same way.

FlagEnabled when
multiTenantconfig.tenancy.multiTenant
notificationsconfig.notifications.enabled
apiKeysconfig.apiKeys.enabled
creditsconfig.payment.enabled and pricingConfig.credits.enabled
aiconfig.ai.enabled
aiByokconfig.ai.enabled and config.ai.byok (users may bring their own keys)
companionconfig.companion.enabled
mcpServerconfig.mcpServer.enabled
emailTrackingconfig.email.tracking.enabled

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 navbarItems entry has an enabled?: boolean and is filtered by enabled !== false (no roles/requires). The default config drives the blog and docs links from config.blog.enabled and config.docs.enabled. Navbar items can also nest subitems (dropdown), lay that dropdown out as a multi-column grid with columns (1 | 2 | 3 | 4, default 1), and set primary: true to appear in the mobile bottom nav. A dropdown parent may omit href to become a pure trigger that only opens its submenu instead of navigating. Mark a link external for hard navigation to a URL served outside the app; external links open in a new tab by default, so set newTab: false to 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.

ItemLocationGateHidden when
Organization (sidebar link)config/sidebar.tsrequires: ["multiTenant"]config.tenancy.multiTenant is false
API keys (admin tab)section-tabs.ts adminrequires: ["apiKeys"]config.apiKeys.enabled is false
Developers (settings tab)section-tabs.ts settingsrequires: ["apiKeys"]config.apiKeys.enabled is false
Organizations (admin tab)section-tabs.ts adminrequires: ["multiTenant"]config.tenancy.multiTenant is false
Admin (sidebar category + link)config/sidebar.tsroles: ["admin"]viewer is not an admin
Activity (organization tab)section-tabs.ts organizationorgRoles: ["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.

On this page