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.

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 accepts multiTenant, notifications, apiKeys, credits, and adminNotifications - but only the first four are resolved by the runtime check (isFeatureEnabled maps them to config.tenancy.multiTenant, config.notifications.enabled, config.apiKeys.enabled, and config.payment.enabled && pricingConfig.credits.enabled). adminNotifications falls through to the true default, so it never hides anything - don't rely on it as a gate.

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