logo
Published on

Building a Global Nav Bar with SPFx Application Customizer

The default SharePoint global navigation is limited — it can only show flat links, does not support mega-menu layouts from a custom data source, and cannot be styled to match your organisation's brand guidelines.
An SPFx Application Customizer injected into the Top placeholder gives you complete control: nav items from a SharePoint list, a multi-column mega-menu, responsive hamburger collapse, and styling that matches your tenant's brand.
This article builds a complete, production-ready global nav bar driven by a SharePoint list.


🗂️ Step 1 — Design the Navigation Data Structure

Store nav items in a SharePoint list called GlobalNavigation with these columns:

ColumnTypePurpose
TitleSingle line of textLink label
UrlSingle line of textLink href
ParentTitleSingle line of textParent item title (empty = top-level)
SortOrderNumberDisplay order within a group
OpenInNewTabYes/NoWhether to open the link in a new tab
IsActiveYes/NoToggle links on/off without deleting

Top-level items have ParentTitle empty. Child items reference their parent's Title in ParentTitle. This gives you a two-level hierarchy without needing a lookup column.


🔧 Step 2 — Navigation Service

src/extensions/globalNav/services/NavService.ts

import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { Caching } from '@pnp/queryable';

export interface INavItem {
  title: string;
  url: string;
  openInNewTab: boolean;
  sortOrder: number;
  children: INavItem[];
}

export class NavService {
  private readonly _sp: SPFI;

  constructor(sp: SPFI) {
    this._sp = sp;
  }

  public async getNavItems(): Promise<INavItem[]> {
    // Cache nav for 30 minutes — editors update the list, not push a deployment
    const cachedSp = this._sp.using(
      Caching({ store: 'session', expireFunc: () => new Date(Date.now() + 30 * 60 * 1000) })
    );

    const items = await cachedSp.web.lists
      .getByTitle('GlobalNavigation')
      .items
      .filter(`IsActive eq 1`)
      .select('Title', 'Url', 'ParentTitle', 'SortOrder', 'OpenInNewTab')
      .orderBy('SortOrder', true)
      .top(200)();

    // Build the two-level hierarchy
    const topLevel: INavItem[] = items
      .filter((i: any) => !i.ParentTitle)
      .map((i: any) => ({
        title: i.Title,
        url: i.Url ?? '#',
        openInNewTab: i.OpenInNewTab ?? false,
        sortOrder: i.SortOrder ?? 0,
        children: items
          .filter((child: any) => child.ParentTitle === i.Title)
          .map((child: any) => ({
            title: child.Title,
            url: child.Url ?? '#',
            openInNewTab: child.OpenInNewTab ?? false,
            sortOrder: child.SortOrder ?? 0,
            children: []
          }))
          .sort((a: INavItem, b: INavItem) => a.sortOrder - b.sortOrder)
      }))
      .sort((a: INavItem, b: INavItem) => a.sortOrder - b.sortOrder);

    return topLevel;
  }
}

🧩 Step 3 — Global Nav Bar Component

src/extensions/globalNav/components/GlobalNavBar.tsx

import * as React from 'react';
import {
  makeStyles, tokens, Button, Popover,
  PopoverTrigger, PopoverSurface, Link
} from '@fluentui/react-components';
import { NavigationRegular, DismissRegular } from '@fluentui/react-icons';
import { INavItem } from '../services/NavService';

const useStyles = makeStyles({
  nav: {
    background: '#0f3460',               // Brand primary — customise as needed
    display: 'flex',
    alignItems: 'center',
    padding: `0 ${tokens.spacingHorizontalL}`,
    height: '48px',
    position: 'sticky',
    top: 0,
    zIndex: 1000,
    boxShadow: tokens.shadow4
  },
  logo: {
    color: '#ffffff',
    fontWeight: tokens.fontWeightSemibold,
    fontSize: tokens.fontSizeBase400,
    textDecoration: 'none',
    marginRight: tokens.spacingHorizontalXL
  },
  desktopLinks: {
    display: 'flex',
    gap: tokens.spacingHorizontalXS,
    flex: 1,
    // Hide on mobile
    '@media (max-width: 768px)': { display: 'none' }
  },
  navButton: {
    color: '#ffffff',
    '&:hover': { background: 'rgba(255,255,255,0.15)' }
  },
  megaMenu: {
    display: 'grid',
    gridTemplateColumns: 'repeat(3, 1fr)',
    gap: tokens.spacingVerticalM,
    padding: tokens.spacingVerticalM,
    minWidth: '480px'
  },
  childLink: {
    display: 'block',
    padding: `${tokens.spacingVerticalXS} 0`,
    color: tokens.colorNeutralForeground1,
    textDecoration: 'none',
    fontSize: tokens.fontSizeBase200,
    '&:hover': { color: tokens.colorBrandForeground1 }
  },
  // Mobile hamburger
  hamburger: {
    display: 'none',
    marginLeft: 'auto',
    color: '#ffffff',
    '@media (max-width: 768px)': { display: 'flex' }
  },
  mobileMenu: {
    position: 'fixed',
    top: '48px',
    left: 0,
    right: 0,
    background: '#0f3460',
    padding: tokens.spacingVerticalM,
    zIndex: 999,
    display: 'flex',
    flexDirection: 'column',
    gap: tokens.spacingVerticalS
  },
  mobileLink: {
    color: '#ffffff',
    textDecoration: 'none',
    padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
    borderRadius: tokens.borderRadiusMedium,
    '&:hover': { background: 'rgba(255,255,255,0.15)' }
  }
});

interface IGlobalNavBarProps {
  siteUrl: string;
  siteName: string;
  navItems: INavItem[];
}

const GlobalNavBar: React.FC<IGlobalNavBarProps> = ({ siteUrl, siteName, navItems }) => {
  const styles = useStyles();
  const [mobileOpen, setMobileOpen] = React.useState(false);

  return (
    <>
      <nav className={styles.nav} aria-label="Global navigation">
        <a href={siteUrl} className={styles.logo}>{siteName}</a>

        {/* Desktop nav */}
        <div className={styles.desktopLinks}>
          {navItems.map(item => (
            item.children.length > 0 ? (
              <Popover key={item.title} positioning="below-start">
                <PopoverTrigger>
                  <Button
                    className={styles.navButton}
                    appearance="transparent"
                  >
                    {item.title}
                  </Button>
                </PopoverTrigger>
                <PopoverSurface>
                  <div className={styles.megaMenu}>
                    {item.children.map(child => (
                      <a
                        key={child.title}
                        href={child.url}
                        className={styles.childLink}
                        target={child.openInNewTab ? '_blank' : '_self'}
                        rel={child.openInNewTab ? 'noopener noreferrer' : undefined}
                      >
                        {child.title}
                      </a>
                    ))}
                  </div>
                </PopoverSurface>
              </Popover>
            ) : (
              <Button
                key={item.title}
                className={styles.navButton}
                appearance="transparent"
                as="a"
                href={item.url}
                target={item.openInNewTab ? '_blank' : '_self'}
              >
                {item.title}
              </Button>
            )
          ))}
        </div>

        {/* Mobile hamburger */}
        <Button
          className={styles.hamburger}
          appearance="transparent"
          icon={mobileOpen ? <DismissRegular /> : <NavigationRegular />}
          onClick={() => setMobileOpen(o => !o)}
          aria-expanded={mobileOpen}
          aria-label="Toggle navigation menu"
        />
      </nav>

      {/* Mobile menu */}
      {mobileOpen && (
        <div className={styles.mobileMenu}>
          {navItems.flatMap(item => [
            <a key={item.title} href={item.url} className={styles.mobileLink}
              onClick={() => setMobileOpen(false)}>
              <strong>{item.title}</strong>
            </a>,
            ...item.children.map(child => (
              <a key={child.title} href={child.url}
                className={styles.mobileLink}
                style={{ paddingLeft: '32px' }}
                onClick={() => setMobileOpen(false)}>
                {child.title}
              </a>
            ))
          ])}
        </div>
      )}
    </>
  );
};

export default GlobalNavBar;

🔧 Step 4 — Wire Up in the Application Customizer

public async onInit(): Promise<void> {
  await super.onInit();
  this._sp = spfi().using(SPFx(this.context));
  this._navService = new NavService(this._sp);
  this.context.placeholderProvider.changedEvent.add(this, this._renderNav);
  await this._renderNav();
  return Promise.resolve();
}

private async _renderNav(): Promise<void> {
  if (!this._topPlaceholder) {
    this._topPlaceholder = this.context.placeholderProvider
      .tryCreateContent(PlaceholderName.Top, { onDispose: this._onDispose });
  }

  if (!this._topPlaceholder?.domElement) return;

  const navItems = await this._navService.getNavItems();

  ReactDOM.render(
    React.createElement(FluentProvider, { theme: webLightTheme },
      React.createElement(GlobalNavBar, {
        siteUrl: this.context.pageContext.web.absoluteUrl,
        siteName: this.properties.siteName ?? this.context.pageContext.web.title,
        navItems
      })
    ),
    this._topPlaceholder.domElement
  );
}

🚀 Deploy

npm run build
npm run bundle -- --ship
npm run package-solution -- --ship

Register tenant-wide via PowerShell to apply to all sites automatically:

Add-PnPCustomAction `
  -Title "GlobalNavBar" `
  -Name "GlobalNavBar" `
  -Location "ClientSideExtension.ApplicationCustomizer" `
  -ClientSideComponentId "your-component-id" `
  -ClientSideComponentProperties '{"siteName":"Contoso Intranet"}' `
  -Scope Tenant

📂 GitHub Source

View full SPFx project on GitHub:SPFx global nav bar — React + PnPjs mega-menu with SharePoint-driven links

GitHub

✅ Summary

  • Store nav items in a SharePoint list — editors update navigation without touching code or running PowerShell.
  • Build the two-level hierarchy client-side from a flat list using a ParentTitle convention — simple and flexible.
  • Cache nav items in session storage for 30 minutes to avoid a REST call on every page load.
  • Use Fluent UI v9 Popover for desktop mega-menu dropdowns and a React state toggle for mobile.
  • Register tenant-wide with Scope Tenant to apply to all modern SharePoint sites automatically.

Happy coding!

Ad image