articles

Customize SPFx Heft Toolchain: Plugins, Scripts, and Webpack

Learn four ways to customize the SharePoint Framework v1.22 Heft build toolchain: built-in plugins, custom scripts, and ejecting webpack for full control.

Customize SPFx Heft Toolchain: Plugins, Scripts, and Webpack
by Andrew Connell

Last updated December 8, 2025
10 minutes read

Share this

Focus Mode

  • Understanding the customization options
  • Using Heft’s built-in plugins
  • Running custom scripts with the Run Script Plugin
  • Customizing the webpack configuration
  • Ejecting the webpack configuration
  • Choosing the right approach
  • Sample projects
  • Learn more about the SPFx toolchain
  • Download Article Resources
  • Feedback & Comments

SharePoint Framework (SPFx) v1.22 uses the Heft-based build toolchain, and you’ll likely want to customize it at some point. Whether you need to copy files during packaging, add stylesheet linting to your build, or take complete control over webpack configuration, there’s a customization option that fits your needs.

In this article, I’ll walk you through four approaches to customizing the SPFx Heft toolchain, starting with the simplest and progressing to the most advanced. You’ll learn when to use each approach and see practical examples that demonstrate how to implement them in your projects.

Understanding the customization options

Before diving into the specifics, let’s establish what options are available. The Heft toolchain provides three levels of customization:

  1. Built-in Heft plugins: Use existing plugins included with Heft for common tasks like copying or deleting files.
  2. Custom scripts: Run arbitrary code using the Run Script Plugin when built-in plugins don’t meet your needs.
  3. Ejecting webpack: Take full control of the webpack configuration for complete customization.

Each approach has trade-offs between simplicity and flexibility. I recommend starting with the simplest option that meets your requirements and only moving to more advanced approaches when necessary.

Using Heft’s built-in plugins

The simplest way to customize the build toolchain is by using plugins already included in Heft. These plugins handle common scenarios like copying files, deleting files, or setting environment variables.

Let’s walk through a practical example: copying a license file to the SharePoint package folder after the packaging process completes.

Create the file to copy

Start by creating a new file ./sharepoint/assets/LICENSE.md and add your license content to it. This simulates a scenario where you want to include additional files in your SharePoint package output.

Create the local heft.json file

The SPFx project uses a rig configuration defined in ./config/rig.json that points to the @microsoft/spfx-web-build-rig package. To customize the build without modifying that package, create a local ./config/heft.json file that extends the rig configuration:

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

Don’t delete the ./config/rig.json file even though you’re creating a local heft.json. Other parts of the Heft toolchain still use the rig configuration.

Add the copy task to the package-solution phase

Now extend the package-solution phase by adding a new task that uses Heft’s built-in Copy Files Plugin.

Add the following to your heft.json file:

{
  "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json",
  "extends": "@microsoft/spfx-web-build-rig/profiles/default/config/heft.json",
  "phasesByName": {
    "package-solution": {
      "tasksByName": {
        "copy-license": {
          "taskDependencies": ["package-solution"],
          "taskPlugin": {
            "pluginPackage": "@rushstack/heft",
            "pluginName": "copy-files-plugin",
            "options": {
              "copyOperations": [
                {
                  "sourcePath": "./sharepoint/assets",
                  "destinationFolders": ["./sharepoint/solution"],
                  "includeGlobs": ["LICENSE.md"]
                }
              ]
            }
          }
        }
      }
    }
  }
}

The taskDependencies property ensures the copy-license task runs after the package-solution task completes. Without this, your task runs in parallel with other tasks in the phase.

Test the customization

Run the build and package commands to verify your customization works:

heft build
heft package-solution --production

You’ll see the custom task appear in the console output…

Notice the `[package-solution:copy-license]` custom task logged to the console when it ran.

Notice the `[package-solution:copy-license]` custom task logged to the console when it ran.

… and the LICENSE.md file will be in the ./sharepoint/solution folder alongside your *.sppkg file.

Result of the copy-license task placing the LICENSE.md file in the './sharepoint/solution' folder.

Result of the copy-license task placing the LICENSE.md file in the './sharepoint/solution' folder.

Running custom scripts with the Run Script Plugin

When Heft’s built-in plugins don’t address your specific needs, the Run Script Plugin lets you run arbitrary code as part of the build process. A common use case is adding stylesheet linting with Stylelint, something developers frequently added to the old gulp-based toolchain.

Install Stylelint

Start by installing Stylelint and a popular community configuration:

npm install stylelint stylelint-config-standard-scss --save-dev

Next, create a configuration file ./.stylelintrc in your project root:

{
  "extends": "stylelint-config-standard",
  "plugins": ["stylelint-scss"],
  "rules": {
    "at-rule-no-unknown": null,
    "scss/at-rule-no-unknown": true,
    "value-list-comma-space-after": "always-single-line",
    "declaration-empty-line-before": "never"
  }
}

Create the script file

The Run Script Plugin requires a script that exports a runAsync() function. Create ./config/run-script/stylelint.mjs with the following code:

#!/usr/bin/env node
import stylelint from 'stylelint';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);

const RESET = '\x1b[0m';

function log(message, terminal) {
  (terminal)
    ? terminal.writeLine(message)
    : console.log(message);
}

function warn(message, terminal) {
  (terminal)
    ? terminal.writeWarningLine(message)
    : console.warn(`\x1b[33m${message}${RESET}`);
}

function error(message, terminal) {
  (terminal)
    ? terminal.writeErrorLine(message)
    : console.error(`\x1b[31m${message}${RESET}`);
}

async function runStylelint(options) {
  const terminal = (options)
    ? options.heftTaskSession.logger.terminal
    : undefined;

  try {
    const projectFolder = path.join(__dirname, './../..');
    const stylelintPkg = require('stylelint/package.json');
    log(`Using Stylelint version ${stylelintPkg.version}`, terminal);

    const result = await stylelint.lint({
      files: 'src/**/*.scss',
      configFile: path.join(projectFolder, '.stylelintrc'),
      cwd: projectFolder
    });

    if (result.errored || result.results.some(r => r.warnings.length > 0)) {
      for (const fileResult of result.results) {
        if (fileResult.warnings.length > 0) {
          for (const warning of fileResult.warnings) {
            const relativePath = path.relative(projectFolder, fileResult.source);
            const formattedWarning = `${relativePath}:${warning.line}:${warning.column} - (${warning.rule}) ${warning.text}`;
            warn(`Warning: ${formattedWarning}`, terminal);
          }
        }
      }
    }
  } catch (e) {
    error(`Error running stylelint: ${e.message}`, terminal);
  }
}

// Support running as a Heft script
export async function runAsync(options) {
  await runStylelint(options);
}

// Support running directly from command line
if (import.meta.url === `file://${process.argv[1]}`) {
  runStylelint().catch(e => {
    console.error('Error: ', e);
    process.exit(1);
  });
}

The script file must be an ECMAScript Module (*.mjs) because Stylelint’s Node API requires ESM in recent versions. The script supports both running within Heft and directly from the command line, which is useful for testing.

Add the task to the build phase

Update your ./config/heft.json file to add the Stylelint task to the build phase. You can also create a standalone stylelint phase for running the linter independently:

{
  "$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": {
        "stylelint": {
          "taskDependencies": ["sass"],
          "taskPlugin": {
            "pluginPackage": "@rushstack/heft",
            "pluginName": "run-script-plugin",
            "options": {
              "scriptPath": "./config/run-script/stylelint.mjs"
            }
          }
        }
      }
    },
    "stylelint": {
      "tasksByName": {
        "stylelint": {
          "taskPlugin": {
            "pluginPackage": "@rushstack/heft",
            "pluginName": "run-script-plugin",
            "options": {
              "scriptPath": "./config/run-script/stylelint.mjs"
            }
          }
        }
      }
    }
  }
}

The taskDependencies on the build phase task ensures Stylelint runs after the Sass compiler completes.

Test the script

Run these commands to verify everything works:

# run as part of the build phase
heft build

# run as its own phase
heft stylelint

# run the script directly
node ./config/run-script/stylelint.mjs

You’ll see Stylelint output in the console, formatted similarly to ESLint warnings.

Running the stylelint as part of the **build** phase, writing it’s findings to the console

Running the stylelint as part of the **build** phase, writing it’s findings to the console

Customizing the webpack configuration

Another common customization option developers like to implement with SPFx solutions is modifying the webpack configuration to add or update webpack plugins. The SPFx team makes this a simple task through a plugin they created for the SPFx toolchain, the Webpack Patch plugin.

This plugin looks for an optional configuration file in your project that contains a list of scripts you can create. Once the SPFx toolchain creates the webpack configuration, it then calls each script listed in the plugin’s options file. The scripts must export a default function that accept a single parameter, the webpack configuration, and return the configuration back to the caller. You, as the developer, can modify the configuration as you see fit before returning it.

Developers who made this type of change to their gulp-based toolchain SPFx projects will find this familiar because it follows the same pattern.

Let’s see how to implement this by adding the popular Webpack Bundle Analyzer plugin to the build toolchain…

Install the Webpack Bundle Analyzer plugin

Start by installing the webpack bundle analyzer plugin:

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

Create the Webpack patch file

Next, create a new JavaScript file in your project, ./config/webpack-patch/webpack-bundle-analyzer.js, that will receive the webpack configuration. The file exports a default function that accepts the SPFx toolchain-generated webpack configuration. It then modifies it by adding the Webpack Bundle Analyzer to the collection of plugins before returning it back to the caller:

const path = require('path');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = function(webpackConfig) {
  // location of plugin output
  const lastDirName = path.basename(__dirname);
  const projectPath = path.join(__dirname, './../..');
  const webpackStats = path.join(projectPath, 'temp','webpack');

  // ensure plugins collection present
  if (!webpackConfig.plugins) { webpackConfig.plugins = []; }

  // add plugin
  webpackConfig.plugins.push(new BundleAnalyzerPlugin({
    openAnalyzer: false,
    analyzerMode: 'static',
    reportFilename: path.join(webpackStats, `build.stats.html`),
    generateStatsFile: true,
    statsFilename: path.join(webpackStats, `build.stats.json`),
    logLevel: 'error'
  }));

  // return modified webpack config to build toolchain
  return webpackConfig;
};

Register the patch file with the Webpack Patch Plugin

To get the Webpack patch plugin to execute your scripts, add a new file to your project, ./config/webpack-patch.json, with the following contents. The plugin always runs during the build phase and will look for the presence of this configuration file. When found, it will run all scripts listed in the patchFiles array:

{
  "$schema": "https://developer.microsoft.com/en-us/json-schemas/spfx-build/webpack-patch.schema.json",
  "patchFiles": [
    "./config/webpack-patch/webpack-bundle-analyzer.js"
  ]
}

Test the webpack patch plugin

Test the plugin by running the build phase: heft build. Notice the console will indicate it found your patch file:

Webpack Patch Console Message

Webpack Patch Console Message

Once the build phase completes, you’ll see the Webpack bundle analyzer report & data files generated in the ./temp folder:

Webpack Patch Result

Webpack Patch Result

Ejecting the webpack configuration

Sometimes you need complete control over webpack configuration that can’t be achieved through plugins or scripts. In those cases, you can eject the webpack configuration from the toolchain.

Avoid Ejecting - Use Only as Last Resort

Ejecting is a one-way operation. Microsoft doesn’t support the build toolchain on projects that have ejected the webpack configuration. I strongly recommend considering all other options before ejecting.

When to consider ejecting

You should only eject when:

  • The Webpack Patch Plugin doesn’t provide enough control over the configuration
  • Custom Heft plugins can’t achieve your specific requirements
  • You need to fundamentally change how webpack processes your code

In most cases, I find the Webpack Patch Plugin meets customization needs without the downsides of ejecting.

You can learn more about the impact of ejecting the webpack configuration from the SPFx documentation: Ejecting the webpack configuration.

Let’s see how to perform the same changes we applied using the patch method if we went with the eject option:

Eject the configuration

To eject, run:

heft eject-webpack

Customizing the ejected configuration

Once ejected, you have direct access to the webpack configuration files.

To add the Webpack Bundle Analyzer Plugin, start by installing the package:

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

Then, in the ./webpack.config.js, add an import statement for the package after the existing require() statements:

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

Then, find the const plugins = [ line in the getSPFxWebpackConfig() function and add the plugin:

new BundleAnalyzerPlugin({
  analyzerMode: 'static',
  openAnalyzer: false,
  generateStatsFile: true,
  reportFilename: `${__dirname}/temp/webpack/build.stats.html`,
  statsFilename: `${__dirname}/temp/webpack/build.stats.json`,
  logLevel: 'error'
})

Test the ejected configuration

Run a production build to generate the analysis:

heft build --production

Just like the previous example, you’ll now see the Webpack Bundle Analyzer generated report and stats files appear in ./temp/webpack folder.

Choosing the right approach

Here’s my recommendation for choosing the right customization approach:

  1. Start with built-in plugins if you need standard file operations or environment configuration
  2. Use the Run Script Plugin when you need custom logic that built-in plugins don’t provide
  3. Consider the Webpack Patch Plugin for webpack customizations that don’t require full control
  4. Eject as a last resort only when you’ve exhausted all other options

The Heft toolchain provides extensive customization capabilities without sacrificing maintainability. By extending the rig configuration rather than replacing it, you benefit from Microsoft’s ongoing updates while still meeting your project’s specific needs.

Sample projects

I’ve created sample projects demonstrating each customization option covered in this article. Fill out the form below to get these sample projects and explore working implementations of:

  • copy-files-plugin-sample: Using the built-in Copy Files Plugin
  • run-script-plugin-sample: Adding Stylelint with the Run Script Plugin
  • webpack-patch-sample: Use the built-in Webpack Patch Plugin
  • ejected-webpack-sample: Customizing an ejected webpack configuration

Learn more about the SPFx toolchain

The Heft-based toolchain opens up powerful customization options for SPFx developers. Understanding how to create custom plugins is just one piece of the puzzle.

I’m running a hands-on SPFx workshop in January that covers the build toolchain in depth. You’ll learn not just the “how” but the “why” behind these tools—the kind of understanding that helps you solve problems on your own.

Learn more and enroll in the workshop: Learn SharePoint Framework Development - January 13-22, 2026.

I’m updating my Mastering the SharePoint Framework course with comprehensive coverage of the new toolchain. Stay tuned for those updates!

What customizations are you planning for your SPFx projects? Have you encountered scenarios where ejecting was necessary? Leave a reaction, share your experience in the comments, or ask questions below!

Download Article Resources

Want the resources for this article? Enter your email and we'll send you the download link.
Andrew Connell, Microsoft MVP, Full-Stack Developer & Chief Course Artisan - Voitanos LLC.
author
Andrew Connell

Microsoft MVP, Full-Stack Developer & Chief Course Artisan - Voitanos LLC.

Andrew Connell is a full stack developer who focuses on Microsoft Azure & Microsoft 365. He’s a 21-year recipient of Microsoft’s MVP award and has helped thousands of developers through the various courses he’s authored & taught. Whether it’s an introduction to the entire ecosystem, or a deep dive into a specific software, his resources, tools, and support help web developers become experts in the Microsoft 365 ecosystem, so they can become irreplaceable in their organization.

Feedback & Questions

newsletter

Join 12,000+ developers for news & insights

No clickbait · 100% free · Unsubscribe anytime.

    Subscribe to Andrew's newsletter for insights & stay on top of the latest news in the Microsoft 365 Space!
    blurry dot in brand primary color
    found this article helpful?

    You'll love these!

    SharePoint Framework v1.22: What's in the Latest SPFx Update

    SharePoint Framework v1.22: What's in the Latest SPFx Update

    December 8, 2025

    Read now

    How To: Create custom Heft plugins to lint stylesheets

    How To: Create custom Heft plugins to lint stylesheets

    December 8, 2025

    Read now

    How To: Create custom Heft plugins to lint stylesheets

    How To: Create custom Heft plugins to lint stylesheets

    December 8, 2025

    Read now

    bi-weekly newsletter

    Join 12,000+ Microsoft 365 full-stack web developers for news, insights & resources. 100% free.

    Subscribe to Andrew's newsletter for insights & stay on top of the latest news in the Microsoft 365 ecosystem!

    No clickbait · 100% free · Unsubscribe anytime.

      Subscribe to Andrew's newsletter for insights & stay on top of the latest news in the Microsoft 365 Space!