- 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:
| Column | Type | Purpose |
|---|---|---|
Title | Single line of text | Link label |
Url | Single line of text | Link href |
ParentTitle | Single line of text | Parent item title (empty = top-level) |
SortOrder | Number | Display order within a group |
OpenInNewTab | Yes/No | Whether to open the link in a new tab |
IsActive | Yes/No | Toggle 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
✅ 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
ParentTitleconvention — 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
Popoverfor desktop mega-menu dropdowns and a React state toggle for mobile. - Register tenant-wide with
Scope Tenantto apply to all modern SharePoint sites automatically.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
