SPFx Application Customizer: Build Global Headers & Footers
Build a global header and footer for SharePoint with an SPFx Application Customizer extension. Complete guide includes tenant-wide deployment instructions.

The Problem SharePoint Add-in Retirement Created
On April 2, 2026, Microsoft officially retired SharePoint Add-ins. Every SharePoint Add-in stopped working across all tenants — no extensions, no exceptions. (Microsoft Learn)
If your organization had a custom branded header or footer deployed via an Add-in, it stopped rendering that day. The only supported replacement is a SharePoint Framework Application Customizer extension — and if you have not migrated yet, this guide will walk you through building one from scratch in 2026.
Even if you were never on Add-ins, Application Customizers are the right tool whenever you need something to appear on every page across your SharePoint tenant: a cookie consent banner, a global navigation bar, an emergency announcement strip, or a branded footer with links and contact information.
Key Takeaways
- Application Customizers are SPFx extensions that inject HTML into two page-level placeholders:
PageHeaderandPageFooter.
- They run on every modern SharePoint page — site pages, list views, document libraries — without any per-page configuration.
- Tenant-wide deployment via the Tenant Wide Extensions list pushes your customizer to all sites automatically, without requiring site owners to install anything.
- The April 2026 SharePoint Add-in retirement deadline means any organization still relying on Add-in-based branding needs to migrate now.
- In SPFx 1.22+, new extension projects use the Heft build system instead of Gulp. The scaffolding and build commands changed.
- Field Customizers are being retired on June 30, 2026 — but Application Customizers and Command Sets are unaffected and fully supported.
What Is an SPFx Application Customizer?
The SharePoint Framework offers four types of extensions that run inside the SharePoint page itself, outside the boundaries of a web part zone:
| Extension Type | Purpose | Status |
|---|---|---|
| Application Customizer | Page-level scripts and HTML via placeholders | Supported |
| Command Set | Buttons in list/library toolbars and context menus | Supported |
| Form Customizer | Override new/edit/view forms in lists | Supported |
| Field Customizer | Custom column rendering in list views | Retiring June 30, 2026 |
Application Customizers sit at the top of this list because they have the broadest surface area: your code runs on every page load across every site in the tenant.
The two available placeholders are:
PageHeader— the strip at the very top of every SharePoint modern page, above the suite bar
PageFooter— the strip at the bottom of every page, below page content
You get a Tens of millions of users interact with custom SPFx solutions on a daily basis in Microsoft 365. (Microsoft 365 Developer Blog) Application Customizers are among the most commonly deployed extension types because the use cases are universal: Header use cases: Footer use cases: Any of these would previously have been deployed as a SharePoint Add-in or a JavaScript injection via a user custom action. Both approaches are now retired. Application Customizer is the supported path forward. Before scaffolding, confirm your environment matches current requirements. Required software: Check your Node version: If you are on Node 18 or 20, use nvm-windows to switch: Install the SPFx toolchain (SPFx 1.23 + Heft): If you have already migrated to the new SPFx CLI preview (introduced in SPFx 1.23), you can use that instead. See the Yeoman to SPFx CLI migration guide for the new workflow. With SPFx 1.22 and later, new projects use Heft instead of Gulp. The Yeoman generator still works but the build scripts have changed. See the full Gulp-to-Heft migration guide if you are upgrading an existing project rather than starting fresh. Run the Yeoman generator: When prompted: The Open export interface IContosoHeaderFooterApplicationCustomizerProperties { export default class ContosoHeaderFooterApplicationCustomizer private _headerPlaceholder: PlaceholderContent | undefined; @override private _renderPlaceholders(): void { if (this._headerPlaceholder) { // --- FOOTER --- if (this._footerPlaceholder) { private _escapeHtml(text: string): string { SharePoint renders placeholders lazily. On some pages, Notice the interface Example JSON you would put in This pattern keeps your extension generic while allowing per-tenant or per-site configuration. The same compiled The // In _renderPlaceholders(): if (this._headerPlaceholder) { The component itself lives at export interface IHeaderProps { const Header: React.FC<IHeaderProps> = ({ message, userDisplayName }) => ( export default Header; When you use Every web part has a bounded blast radius: it only loads on pages where a site owner placed it. Application Customizers are different — they load on every page load in the tenant. A 500 KB synchronous bundle that blocks the main thread is a site-wide performance problem, not a per-page concern. Use the webpack bundle analyzer to see what's inside your For legacy Gulp-based projects, wire it up in Run For components that only appear after user interaction — a dropdown menu, a notification panel, a help widget — use dynamic const MegaMenu = React.lazy( const Header: React.FC<IHeaderProps> = ({ message, userDisplayName }) => { return ( The Starting in January 2026, SharePoint Online enforces The safe approach in SPFx extensions is to use CSS Modules or import styles via // Then reference in your HTML template SPFx compiles SCSS modules into hashed class names at build time and injects a With Heft (SPFx 1.22+), the build commands changed from Gulp. Your Build for production: Then package: This generates a After deployment, go to the Tenant Wide Extensions list in the App Catalog site collection ( For repeatable, scriptable deployments, use PnP PowerShell. This is especially useful in CI/CD pipelines. Refer to the PnP PowerShell admin scripts guide for environment setup if you have not configured PnP PowerShell yet. # Upload and deploy the solution package # Verify deployment The -Scope Tenant Open config/package-solution.json Setting skipFeatureDeployment: true After deployment, your Application Customizer appears in the Tenant Wide Extensions list at your App Catalog URL: Each list item represents one active extension registration. The key columns are: The classic SPFx Workbench ( SPFx 1.23 ships a new debugging toolbar that improves this experience significantly. (SharePoint Framework Debug Toolbar — Microsoft Learn) When you start the local server and add the Placeholder returns Header renders but footer does not: Customizer runs multiple times: private _renderPlaceholders(): void { One of the most powerful Application Customizer patterns is showing the current user's information in the header. Since you have access to // In onInit or _renderPlaceholders: const me = await client // Use me.displayName in your header HTML Refer to the Graph API in SPFx user profiles guide for the full authentication flow and permission configuration needed to call user endpoints. For advanced scenarios like reading SharePoint list data for dynamic navigation, also see PnP JS in SPFx for a cleaner data-access layer. If you build several Application Customizers or web parts that all need the same Graph or PnP wrappers, extract those services into an SPFx library component so each consumer imports a single versioned package from the App Catalog instead of bundling its own copy. If you are migrating from a retired SharePoint Add-in, the pattern is: The key difference from user custom actions (which injected SPFx Application Customizer in Practice: What Organizations Build
Prerequisites
node --version
# Should output v22.x.xnvm install 22
nvm use 22npm install -g @microsoft/generator-sharepoint@1.23.0Scaffold the Application Customizer
yo @microsoft/sharepointPrompt Answer Solution name contoso-header-footerTarget environment SharePoint Online only Component type Extension Extension type Application Customizer Name ContosoHeaderFooterDescription Global header and footer for Contoso intranet
The scaffolded structure will look like this:contoso-header-footer/
├── config/
│ ├── package-solution.json
│ └── serve.json
├── src/
│ └── extensions/
│ └── contosoHeaderFooter/
│ ├── ContosoHeaderFooterApplicationCustomizer.manifest.json
│ ├── ContosoHeaderFooterApplicationCustomizer.ts
│ └── loc/
│ └── myStrings.d.ts
├── package.json
└── rig.json ← new in SPFx 1.22+ (Heft)rig.json file is new in the Heft-based toolchain — it points to @microsoft/spfx-web-build-rig and replaces the old gulpfile.js.Writing the Application Customizer
ContosoHeaderFooterApplicationCustomizer.ts. The scaffolded class extends BaseApplicationCustomizer. You will override the onInit method to attach your header and footer.Basic Placeholder Implementation
import { override } from '@microsoft/decorators';
import {
BaseApplicationCustomizer,
PlaceholderContent,
PlaceholderName
} from '@microsoft/sp-application-base';
headerMessage: string;
footerText: string;
}
extends BaseApplicationCustomizer<IContosoHeaderFooterApplicationCustomizerProperties> {
private _footerPlaceholder: PlaceholderContent | undefined;
public onInit(): Promise<void> {
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceholders);
this._renderPlaceholders();
return Promise.resolve();
}
// --- HEADER ---
if (!this._headerPlaceholder) {
this._headerPlaceholder =
this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top);
}
const message: string =
this.properties.headerMessage || 'Welcome to the Contoso Intranet';
this._headerPlaceholder.domElement.innerHTML = ;
<div class="contoso-header" role="banner">
<div class="contoso-header__inner">
<a class="contoso-header__logo" href="/" aria-label="Contoso Home">
<img src="/sites/intranet/SiteAssets/logo.svg" alt="Contoso" height="32" />
</a>
<span class="contoso-header__message">${this._escapeHtml(message)}</span>
</div>
</div>
}
if (!this._footerPlaceholder) {
this._footerPlaceholder =
this.context.placeholderProvider.tryCreateContent(PlaceholderName.Bottom);
}
const year = new Date().getFullYear();
const footerText: string = this.properties.footerText || 'Contoso Corporation';
this._footerPlaceholder.domElement.innerHTML = ;
<div class="contoso-footer" role="contentinfo">
<div class="contoso-footer__inner">
<span>© ${year} ${this._escapeHtml(footerText)}. All rights reserved.</span>
<nav class="contoso-footer__links" aria-label="Footer navigation">
<a href="/sites/intranet/SitePages/Privacy.aspx">Privacy Policy</a>
<a href="/sites/intranet/SitePages/Accessibility.aspx">Accessibility</a>
<a href="https://support.contoso.com">Help Desk</a>
</nav>
</div>
</div>
}
}
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
}Why
changedEvent MattersPlaceholderName.Top or PlaceholderName.Bottom may not be available when onInit first runs — they become available later during the page load cycle. Subscribing to changedEvent ensures your callback fires again whenever the placeholder provider's state changes, so your header/footer always renders even on complex page layouts.Passing Properties from the Tenant Wide Extensions List
IContosoHeaderFooterApplicationCustomizerProperties. Properties declared here can be passed as JSON via the ClientSideComponentProperties column in the Tenant Wide Extensions list. This allows site collection administrators to customize the header message per site without changing the code.ClientSideComponentProperties:{
"headerMessage": "Internal use only — Contoso employees",
"footerText": "Contoso Legal & Compliance"
}.sppkg runs everywhere; only the properties differ.Using React Components in Your Application Customizer
innerHTML approach works for static banners, but as soon as you need dynamic data — the current user's display name, a notification count, a mega-menu that renders on hover — you want a proper React component tree. SPFx ships React and ReactDOM as part of the framework runtime, so you can render directly into a placeholder's domElement without adding React to your bundle.import * as React from 'react';
import * as ReactDOM from 'react-dom';
import Header from './components/Header';
if (!this._headerPlaceholder) {
this._headerPlaceholder =
this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top);
}
const element: React.ReactElement<IHeaderProps> = React.createElement(Header, {
message: this.properties.headerMessage || 'Welcome to the intranet',
userDisplayName: this.context.pageContext.user.displayName,
siteUrl: this.context.pageContext.web.absoluteUrl
});
ReactDOM.render(element, this._headerPlaceholder.domElement);
}src/extensions/contosoHeaderFooter/components/Header.tsx:import * as React from 'react';
import styles from '../ContosoHeaderFooter.module.scss';
message: string;
userDisplayName: string;
siteUrl: string;
}
<header className={styles.contosoHeader} role="banner">
<div className={styles.inner}>
<a href="/" aria-label="Contoso Home" className={styles.logoLink}>
<img src="/sites/intranet/SiteAssets/logo.svg" alt="Contoso" height={32} />
</a>
<span className={styles.message}>{message}</span>
<span className={styles.userGreeting} aria-live="polite">
Hello, {userDisplayName}
</span>
</div>
</header>
);Cleaning Up on Dispose
ReactDOM.render, you must unmount the component tree when the customizer is disposed. Without this, React roots leak across page navigations in SharePoint's single-page-app shell — the modern SharePoint shell reuses the browser context as users move between sites.@override
protected onDispose(): void {
if (this._headerPlaceholder) {
ReactDOM.unmountComponentAtNode(this._headerPlaceholder.domElement);
}
if (this._footerPlaceholder) {
ReactDOM.unmountComponentAtNode(this._footerPlaceholder.domElement);
}
super.onDispose();
}When to Use Each Approach
Approach When to Use Trade-offs Raw innerHTML Static banners, simple escaped text Fast, no component overhead; XSS risk if not escaped carefully React functional component Dynamic data, user context, event handlers Proper lifecycle; component size adds to bundle React + dynamic import Mega-menus, modals, notification panels Smallest synchronous bundle; secondary chunk loads on demand
The PnP SPFx extensions sample repository contains production-grade Application Customizer examples using React — the react-application-header sample is a useful starting reference for real-world patterns. (PnP SPFx Extensions — GitHub)Optimizing Bundle Size for Tenant-Wide Customizers
Inspect Your Bundle Before Optimizing
.sppkg before making changes:npm install --save-dev webpack-bundle-analyzergulpfile.js:const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
generatedConfiguration.plugins.push(
new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false })
);
return generatedConfiguration;
}
});npm run bundle and open the generated report.html. You will almost always find one large transitive dependency responsible for 40–60% of the bundle — typically moment.js, a full lodash import, or a UI component library you imported wholesale.Lazy-Load Non-Critical UI with Dynamic Import
import() to split them into a separate webpack chunk:import * as React from 'react';
import styles from '../ContosoHeaderFooter.module.scss';
() => import(/ webpackChunkName: "mega-menu" / './MegaMenu')
);
const [menuOpen, setMenuOpen] = React.useState(false);
<header className={styles.contosoHeader} role="banner">
<div className={styles.inner}>
<button
className={styles.menuButton}
aria-expanded={menuOpen}
onClick={() => setMenuOpen(v => !v)}
>
Menu
</button>
{menuOpen && (
<React.Suspense fallback={<div className={styles.menuLoading}>Loading…</div>}>
<MegaMenu onClose={() => setMenuOpen(false)} />
</React.Suspense>
)}
</div>
</header>
);
};MegaMenu chunk is fetched from the same CDN location as your primary bundle on first click, then cached by the browser. Subsequent navigations within the same session load it from memory. (Microsoft Learn — Dynamic loading in SPFx)Bundle Size Ground Rules
Rule Detail Do not re-bundle SPFx framework deps @microsoft/sp-http, @microsoft/sp-application-base, and React are already loaded by the SPFx runtime on every page. They appear as externals in SPFx's generated webpack config — confirm yours hasn't overridden this.Avoid full lodash imports import _ from 'lodash' adds ~70 KB. Use import debounce from 'lodash/debounce' per method, or replace with native Array methods and setTimeout.Avoid moment.js 67 KB minified. Use date-fns (fully tree-shakeable) or native Intl.DateTimeFormat for any date formatting in your header or footer.Target < 50 KB for the synchronous chunk The synchronous chunk blocks page render on every SharePoint page load. Anything heavier should be a lazy import. Import PnP Reusable Controls selectively @pnp/spfx-controls-react is comprehensive but large. Import only the specific control you need — do not import the entire package. (PnP SPFx Reusable Controls)Styling Without Violating SharePoint's CSP
style-src Content Security Policy headers on all GA tenants. Inline tags injected into innerHTML will be blocked. If you have reviewed the CSP enforcement changes, you know that the fix is to use CSS Modules or load styles through the SPFx styles module.@microsoft/load-themed-styles:// Import as a CSS Module (safe under CSP)
import styles from './ContosoHeaderFooter.module.scss';
this._headerPlaceholder.domElement.innerHTML = ;
<div class="${styles.contosoHeader}" role="banner">
...
</div> tag into the page — which is CSP-compliant. Never inject a block directly into domElement.innerHTML.Build and Package
package.json scripts now look like:{
"scripts": {
"build": "heft build --clean",
"bundle": "heft build --clean -- --ship",
"package": "heft build --clean -- --ship && gulp package-solution --ship"
}
}npm run bundlegulp package-solution --ship.sppkg file in sharepoint/solution/. The separation between the build step (now Heft) and the packaging step (still a Gulp task for now) is temporary — SPFx 1.24 will complete the migration. See the Heft migration guide for full details.Deploying to SharePoint Online
Option A: Manual Upload
.sppkg to the Apps for SharePoint library/lists/TenantWideExtensions) and verify a new entry was created automatically.Option B: PnP PowerShell (Recommended)
# Connect to the App Catalog site
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/appcatalog" -Interactive
Add-PnPApp -Path ".\sharepoint\solution\contoso-header-footer.sppkg"
-Scope Tenant
-Overwrite
-Publish
Get-PnPApp -Scope Tenant | Where-Object { $_.Title -like "contoso" } flag uploads to the tenant app catalog and automatically populates the Tenant Wide Extensions list for Application Customizer solution types when skipFeatureDeployment is set to true in package-solution.json.Configuring package-solution.json for Tenant Deployment
and confirm this flag is set:{
"solution": {
"name": "contoso-header-footer-client-side-solution",
"id": "your-solution-guid",
"version": "1.0.0.0",
"skipFeatureDeployment": true,
"isDomainIsolated": false
}
} is what enables tenant-wide deployment. Without it, site owners would need to manually install the app on each site.Tenant Wide Extensions List: Controlling Scope
https://contoso.sharepoint.com/sites/appcatalog/lists/TenantWideExtensionsColumn Purpose Title Display name for this extension registration TenantWideExtensionComponentId The GUID from your manifest TenantWideExtensionLocation ClientSideExtension.ApplicationCustomizer TenantWideExtensionListTemplate Filter to specific list types (leave blank for all pages) TenantWideExtensionWebTemplate Filter to specific site templates (e.g., GROUP#0 for modern team sites)TenantWideExtensionSequence Order when multiple customizers are active ClientSideComponentProperties JSON properties passed to your customizer
Targeting specific site templates is useful when you want different headers for communication sites versus team sites. Set TenantWideExtensionWebTemplate to SITEPAGEPUBLISHING#0 for communication sites only, or GROUP#0 for Microsoft 365 group-connected team sites only./_layouts/15/workbench.aspxDebugging the Application Customizer
) does not support extensions — it only renders web parts. To debug an Application Customizer, you test it in a real SharePoint page.?loadSPFX=true&debugManifestsFile=... query parameters to a real SharePoint page URL, the debugging toolbar now appears in-page to give you direct feedback — no more relying solely on browser console messages.config/serve.jsonLocal Debugging Steps
npm run serve
# or with Heft:
heft build --watch and update the pageUrl to a real SharePoint page in your tenant.undefinedCommon Debugging Issues
:changedEvent
The placeholder is not yet available when your code runs. Ensure you are subscribing to as shown in the code above, not just calling tryCreateContent once in onInit.PageFooter
Some SharePoint page layouts (like full-width pages without sections) do not render the placeholder. Test on a standard page with sections.this.context.msGraphClientFactory
Each navigation in modern pages triggers the page load lifecycle again. Implement a guard check:private _headerRendered = false;
if (!this._headerRendered) {
// render header
this._headerRendered = true;
}
}Connecting to Graph API Data
, you can call the Microsoft Graph API directly from your customizer — the same way you would in an SPFx web part.Get-PnPCustomAction -Scope Siteimport { MSGraphClientV3 } from '@microsoft/sp-http';
const client: MSGraphClientV3 = await this.context.msGraphClientFactory
.getClient('3');
.api('/me')
.select('displayName,jobTitle,officeLocation')
.get();Upgrading Existing Add-in-Based Headers
)