Web parts live inside the canvas. But plenty of customizations need to live outside it — a global header and footer on every page, a custom button on the list command bar, a special renderer for a column in a list view. SPFx extensions are how you do all of that on modern pages, the supported successor to the master-page edits and JSLink tricks of the classic era.
Three kinds of extension
- Application Customizer — injects script and UI into well-known placeholders (Top and Bottom) on every page in a scope. Use it for headers, footers, banners, and global behaviours.
- Command Set — adds buttons to a list or library command bar and context menu, with access to the selected rows.
- Field Customizer — overrides how a column renders in a modern list view (the modern answer to JSLink field overrides).
All three are scaffolded by the same generator — choose Extension instead of WebPart — and all three share the SPFx toolchain, context object, and packaging you already know.
Application Customizer: a global footer
An application customizer's entry point is onInit(). You acquire a placeholder and render into it. Placeholders are created once and you should guard against re-creating them:
import { override } from '@microsoft/decorators';
import { BaseApplicationCustomizer,
PlaceholderContent, PlaceholderName }
from '@microsoft/sp-application-base';
import { escape } from '@microsoft/sp-lodash-subset';
export interface IFooterProps { message: string; }
export default class SiteFooterApplicationCustomizer
extends BaseApplicationCustomizer<IFooterProps> {
private _bottom: PlaceholderContent | undefined;
@override
public onInit(): Promise<void> {
this.context.placeholderProvider.changedEvent.add(this, this._render);
this._render();
return Promise.resolve();
}
private _render(): void {
if (!this._bottom) {
this._bottom = this.context.placeholderProvider.tryCreateContent(
PlaceholderName.Bottom, { onDispose: () => {} });
}
if (this._bottom && this._bottom.domElement) {
this._bottom.domElement.innerHTML =
`<div class="appFooter">${escape(this.properties.message)}</div>`;
}
}
}
placeholderProvider.changedEvent and re-render. Modern pages can recreate placeholders during navigation, and without that subscription your footer disappears the first time a user clicks to another page. Also cache the placeholder and only create it once — repeatedly calling tryCreateContent causes flicker.
Command Set: a list button
A command set declares its commands in the extension manifest (with a title and icon) and implements onExecute. The onListViewUpdated hook lets you enable/disable a command based on selection — for example, only allow "Approve" when exactly one row is selected:
import { BaseListViewCommandSet,
IListViewCommandSetExecuteEventParameters,
ListViewStateChangedEventArgs }
from '@microsoft/sp-listview-extensibility';
import { Dialog } from '@microsoft/sp-dialog';
export default class ContractCommandSet
extends BaseListViewCommandSet<{}> {
public onListViewUpdated(args: ListViewStateChangedEventArgs): void {
const approve = this.tryGetCommand('CMD_APPROVE');
if (approve) {
approve.visible = this.context.listView.selectedRows?.length === 1;
}
}
public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
if (event.itemId === 'CMD_APPROVE') {
const row = event.selectedRows[0];
const id = row.getValueByName('ID');
// call PnPjs / REST to update the item
Dialog.alert(`Approving contract ${id}`);
}
}
}
Field Customizer: rendering a column
The field customizer is the modern, supported replacement for JSLink field overrides. onRenderCell hands you the cell DOM element and the field value, and you render into it — typically a small React component or a styled span:
import { BaseFieldCustomizer,
IFieldCustomizerCellEventParameters }
from '@microsoft/sp-listview-extensibility';
export default class StatusFieldCustomizer
extends BaseFieldCustomizer<{}> {
public onRenderCell(event: IFieldCustomizerCellEventParameters): void {
const status = event.fieldValue;
const colors: { [k: string]: string } = {
'Draft': '#9e9e9e', 'In Review': '#f0ad4e',
'Approved': '#5cb85c', 'Archived': '#777'
};
const span = document.createElement('span');
span.textContent = status; // textContent = safe, no XSS
span.style.cssText =
`padding:2px 8px;border-radius:4px;color:#fff;background:${colors[status] || '#333'}`;
event.domElement.appendChild(span);
}
}
textContent (or React) rather than innerHTML when injecting field values. The data in a cell is user-controlled; textContent renders it as text and closes the XSS door that string-built HTML leaves open.
Deploying & binding extensions
Extensions aren't dropped onto a page like web parts — they're bound to a site, list, or field, usually via a custom action. The cleanest way to declare those bindings is in the package's elements.xml feature framework file, so installing the app provisions them automatically. You can also bind at scale with PnP PowerShell using Add-PnPCustomAction (application customizers) or by setting a field's client-side component id.
# Bind an application customizer to a site with PnP PowerShell
Add-PnPCustomAction -Title "SiteFooter" -Name "SiteFooter" `
-Location "ClientSideExtension.ApplicationCustomizer" `
-ClientSideComponentId "<guid-from-manifest>" `
-ClientSideComponentProperties '{"message":"Contoso Confidential"}'
When to reach for an extension
- Application customizer — anything that must appear on every page: branding header/footer, a compliance banner, analytics, global navigation.
- Command set — list/library actions that operate on selected items.
- Field customizer — per-column visual formatting too complex for JSON column formatting.
Extensions complete the SPFx picture: web parts for in-canvas content, extensions for everything around it. Combined with the AAD/Graph clients in the next article, they let you build experiences that feel native to SharePoint and Teams while staying fully within the supported, future-proof model.