Building Custom Heft Plugins for SharePoint Framework (Part 2)

Why Build Custom Heft Plugins?

In the old Gulp-based SPFx toolchain, you could extend the build process by creating custom Gulp tasks. With Heft, custom plugins are the modern replacement for those tasks.

Gulp Tasks vs Heft Plugins

Old Approach (Gulp):

// gulpfile.js
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');

// Custom task
build.task('increment-version', {
execute: (config) => {
return new Promise((resolve, reject) => {
// Custom logic here
resolve();
});
}
});

build.initialize(gulp);

New Approach (Heft):

// VersionIncrementerPlugin.ts
export default class VersionIncrementerPlugin
implements IHeftTaskPlugin<IOptions> {

public apply(taskSession, heftConfig, options) {
taskSession.hooks.run.tapPromise('my-plugin', async () => {
// Custom logic here
});
}
}

Key Differences:

Aspect Gulp Tasks Heft Plugins
Language JavaScript TypeScript (with types!)
Architecture Stream-based Hook-based
Type Safety None Full TypeScript support
Lifecycle Sequential tasks Event-driven hooks
Configuration Code-based JSON schema-validated
Reusability Difficult Easy (npm packages)

When Do You Need a Custom Plugin?

Common Use Cases

✅ Perfect for Custom Plugins:

  1. Version Management

    • Auto-increment version numbers
    • Sync versions across files
    • Generate changelogs
  2. File Operations

    • Copy assets to specific locations
    • Generate files (manifests, configs)
    • Clean up temporary files
  3. Build Notifications

    • Send Slack/Teams messages
    • Email build reports
    • Update dashboards
  4. Custom Validation

    • Check bundle size limits
    • Validate manifest files
    • Security scanning
  5. Environment Configuration

    • Replace environment tokens
    • Swap API endpoints
    • Inject build metadata
  6. Documentation Generation

    • Generate API docs
    • Create README files
    • Update changelogs

❌ NOT for Custom Plugins (use Webpack Patches instead):

  • Modifying webpack configuration
  • Adding webpack loaders
  • Changing webpack plugins
  • Webpack-specific customizations

Plugin vs Webpack Patch Decision Tree

Build Customisation Decision Flow

Understanding Heft Plugin Architecture

Plugin Lifecycle

Heft plugins integrate into the build process through lifecycle hooks. Here’s how it works:

Heft Plugin Lifecycle

  1. Heft Build Starts

    • The build process is initiated via Heft.
  2. Plugin Registration

    • Heft reads config/heft.json
    • Plugin packages are discovered and loaded
    • plugin.apply() is invoked for each registered plugin
  3. Plugin Hooks Registration

    • Plugins register callbacks against lifecycle hooks
    • Common hooks include:
      • run
      • afterRun
      • Other phase-specific hooks
  4. Build Phase Execution

    • As the build progresses, Heft triggers lifecycle hooks
    • Registered plugin callbacks are executed
    • Context information is passed to the plugin
  5. Plugin Custom Logic

    • Custom plugin code runs:
      • File operations
      • Validation checks
      • Notifications
      • Reporting or enforcement logic
  6. Build Continues

    • Heft resumes the remaining build pipeline

Key Plugin Components

Every Heft plugin consists of these essential parts:

  1. Plugin Class - Implements IHeftTaskPlugin<TOptions>
  2. apply() Method - Entry point where hooks are registered
  3. Options Interface - TypeScript types for configuration
  4. JSON Schema - Validates options at runtime
  5. Metadata File - heft-plugin.json for discovery
  6. Package Definition - package.json with dependencies

Plugin Interface

import type {
HeftConfiguration,
IHeftTaskSession,
IHeftTaskPlugin,
IHeftTaskRunHookOptions,
} from '@rushstack/heft';

interface IMyPluginOptions {
// Your plugin options
}

export default class MyPlugin
implements IHeftTaskPlugin<IMyPluginOptions> {

// Required: Called when plugin is loaded
public apply(
taskSession: IHeftTaskSession,
heftConfiguration: HeftConfiguration,
pluginOptions?: IMyPluginOptions
): void {
// Register hooks here
}
}

Available Lifecycle Hooks

Heft provides several hooks you can tap into:

// Before task runs
taskSession.hooks.run.tapPromise('plugin-name', async (runOptions) => {
// Execute before main task
});

// After task completes
taskSession.hooks.afterRun.tapPromise('plugin-name', async () => {
// Cleanup or post-processing
});

Most Common Hook: taskSession.hooks.run - Executes during task execution

Real-World Example: Version Incrementer Plugin

Let’s build a complete, production-ready plugin that solves a real problem: automatically incrementing version numbers during production builds.

The Problem

In SPFx development, you manage versions in two places:

  1. package.json - 3-part semver: 0.0.1
  2. config/package-solution.json - 4-part SPFx format: 0.0.1.0

Manual version management is error-prone and tedious. We need automation!

SPFx Versioning Format

SPFx uses a 4-part versioning scheme:

Format: major.minor.patch.revision

Part Purpose When to Increment Example
major Breaking changes API changes, major refactors 1.0.0.0 → 2.0.0.0
minor New features New functionality (backward compatible) 0.1.0.0 → 0.2.0.0
patch Bug fixes Hotfixes, minor corrections 0.0.1.0 → 0.0.2.0
revision Build number CI/CD builds, no code changes 0.0.2.0 → 0.0.2.1

Plugin Requirements

Our version incrementer plugin will:

  • ✅ Support all four increment strategies (major, minor, patch, build)
  • ✅ Update both package.json and package-solution.json
  • ✅ Only run on production builds (configurable)
  • ✅ Use proper TypeScript typing
  • ✅ Validate options with JSON schema
  • ✅ Provide clear logging
  • ✅ Handle errors gracefully

Step 1: Project Structure

Create the plugin directory structure:

# From SPFx project root
mkdir -p heft-plugins/version-incrementer-plugin/src
cd heft-plugins/version-incrementer-plugin

Complete structure:

heft-plugins/
└── version-incrementer-plugin/
├── src/
│ ├── VersionIncrementerPlugin.ts # Main plugin
│ └── version-incrementer-plugin.schema.json # Options schema
├── lib/ # Compiled output
│ ├── VersionIncrementerPlugin.js
│ ├── VersionIncrementerPlugin.d.ts
│ └── version-incrementer-plugin.schema.json
├── package.json # Plugin dependencies
├── tsconfig.json # TypeScript config
└── heft-plugin.json # Plugin metadata

Step 2: Create package.json

heft-plugins/version-incrementer-plugin/package.json:

{
"name": "version-incrementer-plugin",
"version": "1.0.0",
"description": "Heft plugin to auto-increment version for SPFx projects",
"main": "lib/VersionIncrementerPlugin.js",
"types": "lib/VersionIncrementerPlugin.d.ts",
"scripts": {
"build": "tsc",
"clean": "rimraf lib"
},
"keywords": [
"heft",
"spfx",
"sharepoint-framework",
"version",
"build-plugin"
],
"dependencies": {
"@rushstack/heft": "^1.1.2",
"@rushstack/node-core-library": "^5.11.4",
"semver": "^7.6.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/semver": "^7.5.0",
"typescript": "~5.8.0"
}
}

Key Dependencies:

  • @rushstack/heft: Core Heft types and interfaces
  • @rushstack/node-core-library: File system utilities (JsonFile, FileSystem)
  • semver: Semantic versioning library for increment logic

Step 3: TypeScript Configuration

heft-plugins/version-incrementer-plugin/tsconfig.json:

{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./lib",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib"]
}

Important Settings:

  • declaration: true - Generate .d.ts files for TypeScript consumers
  • strict: true - Enable all strict type checking
  • resolveJsonModule: true - Allow importing JSON files

Step 4: Plugin Metadata

heft-plugins/version-incrementer-plugin/heft-plugin.json:

{
"$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-plugin.schema.json",
"taskPlugins": [
{
"pluginName": "version-incrementer-plugin",
"entryPoint": "./lib/VersionIncrementerPlugin.js",
"optionsSchema": "./lib/version-incrementer-plugin.schema.json"
}
]
}

What This Does:

  • Tells Heft where to find the plugin entry point
  • Specifies the JSON schema for option validation
  • Registers the plugin name for reference in heft.json

Step 5: Options Schema

heft-plugins/version-incrementer-plugin/src/version-incrementer-plugin.schema.json:

{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Version Incrementer Plugin Options",
"description": "Configuration options for the version incrementer Heft plugin",
"type": "object",
"properties": {
"strategy": {
"type": "string",
"enum": ["major", "minor", "patch", "build"],
"description": "Version increment strategy: major, minor, patch (updates package.json), or build (updates only package-solution.json 4th digit)",
"default": "patch"
},
"productionOnly": {
"type": "boolean",
"description": "Only increment version on production builds (when --production flag is used)",
"default": true
},
"updatePackageSolution": {
"type": "boolean",
"description": "Update config/package-solution.json version",
"default": true
}
},
"additionalProperties": false
}

Schema Benefits:

  • ✅ Runtime validation of options
  • ✅ IDE autocomplete in heft.json
  • ✅ Clear documentation of available options
  • ✅ Prevents typos and invalid configurations

Step 6: Implement the Plugin

heft-plugins/version-incrementer-plugin/src/VersionIncrementerPlugin.ts:

import type {
HeftConfiguration,
IHeftTaskSession,
IHeftTaskPlugin,
IHeftTaskRunHookOptions,
} from '@rushstack/heft';
import { FileSystem, JsonFile } from '@rushstack/node-core-library';
import * as semver from 'semver';
import * as path from 'path';

/**
* Plugin options interface
*/
interface IVersionIncrementerPluginOptions {
/**
* Version increment strategy
* - major: 1.0.0 -> 2.0.0
* - minor: 0.1.0 -> 0.2.0
* - patch: 0.0.1 -> 0.0.2
* - build: Only increment 4th digit in package-solution.json
*/
strategy?: 'major' | 'minor' | 'patch' | 'build';

/**
* Only increment on production builds
* @default true
*/
productionOnly?: boolean;

/**
* Update config/package-solution.json version
* @default true
*/
updatePackageSolution?: boolean;
}

/**
* package.json structure
*/
interface IPackageJson {
version: string;
[key: string]: any;
}

/**
* package-solution.json structure
*/
interface IPackageSolutionJson {
solution: {
version: string;
[key: string]: any;
};
[key: string]: any;
}

const PLUGIN_NAME = 'version-incrementer-plugin';

/**
* Heft plugin to automatically increment version numbers
* during production builds
*/
export default class VersionIncrementerPlugin
implements IHeftTaskPlugin<IVersionIncrementerPluginOptions> {

/**
* Plugin entry point - registers lifecycle hooks
*/
public apply(
taskSession: IHeftTaskSession,
heftConfiguration: HeftConfiguration,
pluginOptions?: IVersionIncrementerPluginOptions
): void {

// Register on the 'run' lifecycle hook
taskSession.hooks.run.tapPromise(
PLUGIN_NAME,
async (runOptions: IHeftTaskRunHookOptions) => {
const logger = taskSession.logger;
const projectRoot = heftConfiguration.buildFolderPath;

// Apply default options
const options: Required<IVersionIncrementerPluginOptions> = {
strategy: pluginOptions?.strategy || 'patch',
productionOnly: pluginOptions?.productionOnly !== false,
updatePackageSolution: pluginOptions?.updatePackageSolution !== false,
};

// Check if this is a production build
const isProductionBuild =
(runOptions as any).production === true ||
taskSession.parameters.production;

// Skip if not production and productionOnly is true
if (options.productionOnly && !isProductionBuild) {
logger.terminal.writeVerboseLine(
`[${PLUGIN_NAME}] Skipping version increment (not a production build)`
);
return;
}

logger.terminal.writeLine(
`[${PLUGIN_NAME}] Starting version increment with strategy: ${options.strategy}`
);

try {
// Update package.json
await this._updatePackageJson(
projectRoot,
options.strategy,
logger
);

// Update package-solution.json if requested
if (options.updatePackageSolution) {
await this._updatePackageSolution(
projectRoot,
options.strategy,
logger
);
}

logger.terminal.writeLine(
`[${PLUGIN_NAME}] ✅ Version increment completed successfully!`
);

} catch (error) {
logger.terminal.writeErrorLine(
`[${PLUGIN_NAME}] ❌ Error: ${error}`
);
throw error; // Fail the build
}
}
);
}

/**
* Update package.json version
*/
private async _updatePackageJson(
projectRoot: string,
strategy: 'major' | 'minor' | 'patch' | 'build',
logger: any
): Promise<string> {

const packageJsonPath = path.join(projectRoot, 'package.json');

// Read current package.json
const packageJson: IPackageJson =
await JsonFile.loadAsync(packageJsonPath);

const currentVersion = packageJson.version;
logger.terminal.writeLine(
`[${PLUGIN_NAME}] Current package.json version: ${currentVersion}`
);

// For 'build' strategy, we don't change package.json
if (strategy === 'build') {
logger.terminal.writeLine(
`[${PLUGIN_NAME}] Using 'build' strategy - package.json unchanged`
);
return currentVersion;
}

// Use semver to increment version
const newVersion = semver.inc(currentVersion, strategy);

if (!newVersion) {
throw new Error(
`Failed to increment version from ${currentVersion} using strategy '${strategy}'`
);
}

// Update and save
packageJson.version = newVersion;
await JsonFile.saveAsync(packageJson, packageJsonPath, {
updateExistingFile: true,
});

logger.terminal.writeLine(
`[${PLUGIN_NAME}] ✓ Updated package.json: ${currentVersion}${newVersion}`
);

return newVersion;
}

/**
* Update config/package-solution.json version
*/
private async _updatePackageSolution(
projectRoot: string,
strategy: 'major' | 'minor' | 'patch' | 'build',
logger: any
): Promise<void> {

const solutionPath = path.join(
projectRoot,
'config',
'package-solution.json'
);

// Check if file exists
if (!await FileSystem.existsAsync(solutionPath)) {
logger.terminal.writeWarningLine(
`[${PLUGIN_NAME}] package-solution.json not found, skipping`
);
return;
}

// Read current version
const solutionJson: IPackageSolutionJson =
await JsonFile.loadAsync(solutionPath);

const currentVersion = solutionJson.solution.version;
logger.terminal.writeLine(
`[${PLUGIN_NAME}] Current package-solution.json version: ${currentVersion}`
);

let newVersion: string;

if (strategy === 'build') {
// Only increment 4th digit (revision)
newVersion = this._incrementBuildNumber(currentVersion);
} else {
// Update first 3 digits from package.json, reset revision to 0
const packageJsonPath = path.join(projectRoot, 'package.json');
const packageJson: IPackageJson =
await JsonFile.loadAsync(packageJsonPath);

const versionParts = packageJson.version.split('.');

if (versionParts.length >= 3) {
newVersion = `${versionParts[0]}.${versionParts[1]}.${versionParts[2]}.0`;
} else {
newVersion = `${packageJson.version}.0`;
}
}

// Update and save
solutionJson.solution.version = newVersion;
await JsonFile.saveAsync(solutionJson, solutionPath, {
updateExistingFile: true,
});

logger.terminal.writeLine(
`[${PLUGIN_NAME}] ✓ Updated package-solution.json: ${currentVersion}${newVersion}`
);
}

/**
* Increment only the 4th digit (revision) of a version string
* Example: 0.0.2.5 -> 0.0.2.6
*/
private _incrementBuildNumber(version: string): string {
const parts = version.split('.');

if (parts.length === 4) {
const revision = parseInt(parts[3], 10) + 1;
return `${parts[0]}.${parts[1]}.${parts[2]}.${revision}`;
} else if (parts.length === 3) {
// If only 3 parts, add .1
return `${version}.1`;
} else {
throw new Error(`Invalid version format: ${version}`);
}
}
}

Code Walkthrough

1. Plugin Class Structure

export default class VersionIncrementerPlugin 
implements IHeftTaskPlugin<IVersionIncrementerPluginOptions>
  • Must implement IHeftTaskPlugin<TOptions> interface
  • Generic type TOptions provides type safety for plugin options

2. The apply() Method

public apply(
taskSession: IHeftTaskSession,
heftConfiguration: HeftConfiguration,
pluginOptions?: IVersionIncrementerPluginOptions
): void
  • Called when plugin is loaded
  • Receives task session, configuration, and user-provided options
  • Registers lifecycle hooks here

3. Hook Registration

taskSession.hooks.run.tapPromise(PLUGIN_NAME, async (runOptions) => {
// Plugin logic
});
  • tapPromise for async operations
  • First parameter is plugin name (for logging)
  • Second parameter is async callback function

4. Production Check

const isProductionBuild = 
(runOptions as any).production === true ||
taskSession.parameters.production;
  • Checks if --production flag was used
  • Allows skipping version increment during development

5. File Operations

const packageJson = await JsonFile.loadAsync(packageJsonPath);
packageJson.version = newVersion;
await JsonFile.saveAsync(packageJson, packageJsonPath, {
updateExistingFile: true
});
  • Uses JsonFile from @rushstack/node-core-library
  • Safer than fs.readFile + JSON.parse (handles errors, formatting)

6. Logging

logger.terminal.writeLine(`[${PLUGIN_NAME}] Message`);
logger.terminal.writeErrorLine(`[${PLUGIN_NAME}] Error`);
logger.terminal.writeVerboseLine(`[${PLUGIN_NAME}] Debug`);
  • logger.terminal outputs to console
  • Prefix with plugin name for clarity

Step 7: Register Plugin in SPFx Project

Now let’s integrate the plugin into your SPFx project.

7.1 Update Project package.json

Add npm workspaces to link the local plugin:

{
"name": "my-heft-webpart",
"version": "0.0.1",
"private": true,
"workspaces": [
"heft-plugins/version-incrementer-plugin"
],
"scripts": {
"build": "heft test --clean --production && heft package-solution --production",
"start": "heft start --clean"
}
}

Why workspaces?

  • Links local plugin without publishing to npm
  • Changes to plugin are immediately available
  • Perfect for development and testing

7.2 Create config/heft.json

Create (or update) config/heft.json in your SPFx project:

{
"$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json",
"extends": "@microsoft/spfx-web-build-rig/profiles/default/config/heft.json",

"phasesByName": {
"build": {
"tasksByName": {
"version-incrementer": {
"taskDependencies": ["set-browserslist-ignore-old-data-env-var"],
"taskPlugin": {
"pluginPackage": "version-incrementer-plugin",
"pluginName": "version-incrementer-plugin",
"options": {
"strategy": "patch",
"productionOnly": true,
"updatePackageSolution": true
}
}
}
}
}
}
}

Configuration Breakdown:

Field Purpose Value
extends Inherit SPFx base config Rig path
phasesByName.build Target build phase Built-in phase
tasksByName Define custom tasks Your plugin
taskDependencies Run after specific tasks Environment setup
pluginPackage Plugin package name From package.json
pluginName Plugin identifier From heft-plugin.json
options Plugin configuration Validated by schema

Task Dependencies:

set-browserslist-ignore-old-data-env-var

version-incrementer (YOUR PLUGIN)

sass

typescript

Setting taskDependencies ensures your plugin runs at the right time.

Step 8: Install and Build

# Install plugin dependencies
cd heft-plugins/version-incrementer-plugin
npm install

# Build the plugin
npm run build

# IMPORTANT: Copy schema to lib folder
# (TypeScript doesn't copy .json files)
cp src/version-incrementer-plugin.schema.json lib/

# Return to project root
cd ../..

# Install workspace (links plugin)
npm install

Critical Step

Don't forget to copy the schema file to lib/! Heft looks for it at runtime.

Step 9: Test the Plugin

Test 1: Development Build (Should Skip)

npm start

Expected Output:

---- start started ----
[build:version-incrementer] Skipping version increment (not a production build)
[build:sass] Generating sass typings...

✅ Plugin skips during development!

Test 2: Production Build (Should Increment)

# Before build - check current versions
cat package.json | grep version
# "version": "0.0.1"

# Run production build
npm run build

Expected Output:

---- build started ----
[build:set-browserslist-ignore-old-data-env-var] Setting environment variable...
[build:version-incrementer] Starting version increment with strategy: patch
[build:version-incrementer] Current package.json version: 0.0.1
[build:version-incrementer] ✓ Updated package.json: 0.0.1 → 0.0.2
[build:version-incrementer] Current package-solution.json version: 0.0.1.0
[build:version-incrementer] ✓ Updated package-solution.json: 0.0.1.0 → 0.0.2.0
[build:version-incrementer] ✅ Version increment completed successfully!
[build:sass] Generating sass typings...
[build:typescript] Using TypeScript version 5.8.3
...

Verify Results

Check package.json:

cat package.json | grep version
# "version": "0.0.2" ✅

Check package-solution.json:

cat config/package-solution.json | grep version
# "version": "0.0.2.0" ✅

Check .sppkg file:

# Extract manifest from generated package
unzip -p sharepoint/solution/*.sppkg manifest.json | grep version
# "version": "0.0.2.0" ✅

Step 10: Test Different Strategies

Strategy: minor

Update config/heft.json:

{
"options": {
"strategy": "minor",
"productionOnly": true,
"updatePackageSolution": true
}
}

Run build:

Before: 0.0.2 → 0.0.2.0
After: 0.1.0 → 0.1.0.0

Strategy: build

Update config/heft.json:

{
"options": {
"strategy": "build",
"productionOnly": true,
"updatePackageSolution": true
}
}

Run build:

Before: 0.1.0 (unchanged) → 0.1.0.0
After: 0.1.0 (unchanged) → 0.1.0.1

Perfect for CI/CD scenarios where you only want to bump the build number!

Understanding Plugin Execution in the Pipeline

Complete Build Pipeline

When you run npm run build, here’s the complete execution flow:

SharePoint Solution Build Flow

Why execution order matters:

  1. ✅ Version updates before compilation
  2. ✅ TypeScript compilation includes new version
  3. ✅ Webpack bundles reference updated version
  4. ✅ .sppkg file has correct version metadata

Best Practices for Plugin Development

1. Error Handling

Always handle errors gracefully and fail the build:

try {
// Plugin logic
await this._updatePackageJson(projectRoot, strategy, logger);
} catch (error) {
logger.terminal.writeErrorLine(`[${PLUGIN_NAME}] Error: ${error}`);
throw error; // Fail the build!
}

Don’t do this:

catch (error) {
console.log('Error:', error); // Silent failure!
}

2. Logging

Provide clear, prefixed logging:

Good:
logger.terminal.writeLine(`[my-plugin] Starting process...`);
logger.terminal.writeLine(`[my-plugin] ✓ Updated file.json`);
logger.terminal.writeErrorLine(`[my-plugin] ❌ Failed to read file`);

Bad:
console.log('Starting...'); // No context
console.log('Done'); // Not clear

3. Type Safety

Use TypeScript interfaces for everything:

Good:
interface IPluginOptions {
strategy: 'major' | 'minor' | 'patch';
enabled: boolean;
}

Bad:
// Using 'any' everywhere
function apply(taskSession: any, config: any, options: any) { }

4. Configuration Validation

Always provide JSON schema:

Good:
{
"type": "string",
"enum": ["major", "minor", "patch"],
"default": "patch"
}

Bad:
// No schema - typos cause silent failures

5. Production-Only Operations

For expensive operations, check production flag:

Good:
if (options.productionOnly && !isProductionBuild) {
logger.terminal.writeVerboseLine('Skipping (dev build)');
return;
}

Bad:
// Always runs, slows down development

6. File Operations

Use Rush Stack utilities, not raw Node.js:

Good:
const data = await JsonFile.loadAsync(filePath);
await JsonFile.saveAsync(data, filePath, { updateExistingFile: true });

Bad:
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
fs.writeFileSync(filePath, JSON.stringify(data));

7. Plugin Naming

Use consistent naming across files:

✅ Good:
Plugin class: VersionIncrementerPlugin
Package name: version-incrementer-plugin
Task name: version-incrementer
Log prefix: [version-incrementer]

❌ Bad:
Different names everywhere

Conclusion

Custom Heft plugins unlock the full power of the SPFx 1.22 build system. They provide:

Type-safe extensibility with TypeScript
Clean architecture with lifecycle hooks
Validated configuration via JSON schemas
Better performance than Gulp tasks
Reusability across projects

Key Takeaways

  1. Plugins replace Gulp tasks - They’re the modern way to extend SPFx builds
  2. Use TypeScript - Full type safety and better developer experience
  3. Hook into lifecycle - taskSession.hooks.run.tapPromise() is your entry point
  4. Validate options - JSON schema prevents configuration errors
  5. Handle errors - Always throw to fail the build
  6. Log clearly - Prefix messages with plugin name
  7. Test thoroughly - Both dev and production builds

Resources

Official Documentation:

Sample Code:

Previous Article:

Happy plugin development! 🚀

Author: Ejaz Hussain
Link: https://office365clinic.com/2026/01/13/understanding-heft-in-spfx-part2/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.