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

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>`;
    }
  }
}
Subscribe to 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);
  }
}
Use 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

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.

DS

Dhaval Shah

Cloud Solution Architect — 18+ years across SharePoint, Microsoft 365, and Azure AI.