← Back to Blog

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.

SPFx Application Customizer: Build Global Headers & Footers

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: PageHeader and PageFooter.

  • 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 TypePurposeStatus
Application CustomizerPage-level scripts and HTML via placeholdersSupported
Command SetButtons in list/library toolbars and context menusSupported
Form CustomizerOverride new/edit/view forms in listsSupported
Field CustomizerCustom column rendering in list viewsRetiring 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

node to render whatever HTML you want into both placeholders. React, plain HTML, or a third-party framework — all valid.

SPFx Application Customizer in Practice: What Organizations Build

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:

  • Global navigation bar with megamenu

  • Announcement banners ("System maintenance this weekend")

  • Logged-in user's name and profile photo

  • Breadcrumb navigation override

  • GDPR cookie consent banner (required to fire before page interaction)

Footer use cases:

  • Company branding: logo, copyright, legal links

  • Help desk contact information

  • Site-specific metadata (last modified date, site owner)

  • Accessibility statement link

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.

Prerequisites

Before scaffolding, confirm your environment matches current requirements.

Required software:


  • A SharePoint Online tenant with a configured App Catalog

  • VS Code (recommended)

Check your Node version:

node --version
# Should output v22.x.x

If you are on Node 18 or 20, use nvm-windows to switch:

nvm install 22
nvm use 22

Install the SPFx toolchain (SPFx 1.23 + Heft):

npm install -g @microsoft/generator-sharepoint@1.23.0

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.

Scaffold the Application Customizer

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:

yo @microsoft/sharepoint

When prompted:

PromptAnswer
Solution namecontoso-header-footer
Target environmentSharePoint Online only
Component typeExtension
Extension typeApplication Customizer
NameContosoHeaderFooter
DescriptionGlobal 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)

The 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

Open 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';

export interface IContosoHeaderFooterApplicationCustomizerProperties {
headerMessage: string;
footerText: string;
}

export default class ContosoHeaderFooterApplicationCustomizer
extends BaseApplicationCustomizer<IContosoHeaderFooterApplicationCustomizerProperties> {

private _headerPlaceholder: PlaceholderContent | undefined;
private _footerPlaceholder: PlaceholderContent | undefined;

@override
public onInit(): Promise<void> {
this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceholders);
this._renderPlaceholders();
return Promise.resolve();
}

private _renderPlaceholders(): void {
// --- HEADER ---
if (!this._headerPlaceholder) {
this._headerPlaceholder =
this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top);
}

if (this._headerPlaceholder) {
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>
;
}

// --- FOOTER ---
if (!this._footerPlaceholder) {
this._footerPlaceholder =
this.context.placeholderProvider.tryCreateContent(PlaceholderName.Bottom);
}

if (this._footerPlaceholder) {
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>&copy; ${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>
;
}
}

private _escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}

Why changedEvent Matters

SharePoint renders placeholders lazily. On some pages, PlaceholderName.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

Notice the interface 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.

Example JSON you would put in ClientSideComponentProperties:

{
"headerMessage": "Internal use only — Contoso employees",
"footerText": "Contoso Legal & Compliance"
}

This pattern keeps your extension generic while allowing per-tenant or per-site configuration. The same compiled .sppkg runs everywhere; only the properties differ.

Using React Components in Your Application Customizer

The 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';

// In _renderPlaceholders():
if (!this._headerPlaceholder) {
this._headerPlaceholder =
this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top);
}

if (this._headerPlaceholder) {
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);
}

The component itself lives at src/extensions/contosoHeaderFooter/components/Header.tsx:

import * as React from 'react';
import styles from '../ContosoHeaderFooter.module.scss';

export interface IHeaderProps {
message: string;
userDisplayName: string;
siteUrl: string;
}

const Header: React.FC<IHeaderProps> = ({ message, userDisplayName }) => (
<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>
);

export default Header;

Cleaning Up on Dispose

When you use 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

ApproachWhen to UseTrade-offs
Raw innerHTMLStatic banners, simple escaped textFast, no component overhead; XSS risk if not escaped carefully
React functional componentDynamic data, user context, event handlersProper lifecycle; component size adds to bundle
React + dynamic importMega-menus, modals, notification panelsSmallest 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

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.

Inspect Your Bundle Before Optimizing

Use the webpack bundle analyzer to see what's inside your .sppkg before making changes:

npm install --save-dev webpack-bundle-analyzer

For legacy Gulp-based projects, wire it up in gulpfile.js:

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
build.configureWebpack.mergeConfig({
additionalConfiguration: (generatedConfiguration) => {
generatedConfiguration.plugins.push(
new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false })
);
return generatedConfiguration;
}
});

Run 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

For components that only appear after user interaction — a dropdown menu, a notification panel, a help widget — use dynamic import() to split them into a separate webpack chunk:

import * as React from 'react';
import styles from '../ContosoHeaderFooter.module.scss';

const MegaMenu = React.lazy(
() => import(/ webpackChunkName: "mega-menu" / './MegaMenu')
);

const Header: React.FC<IHeaderProps> = ({ message, userDisplayName }) => {
const [menuOpen, setMenuOpen] = React.useState(false);

return (
<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>
);
};

The 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

RuleDetail
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 importsimport _ from 'lodash' adds ~70 KB. Use import debounce from 'lodash/debounce' per method, or replace with native Array methods and setTimeout.
Avoid moment.js67 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 chunkThe 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

Starting in January 2026, SharePoint Online enforces style-src Content Security Policy headers on all GA tenants. Inline