AboutExpertiseWorkR&DBlogToolsStartContact

Developing for SharePoint Server Subscription Edition: the practices that hold up

Subscription Edition is supported until at least 2035 and its SharePoint Framework ceiling has not moved since 2023. That combination is the whole job: a long runway on an older stack. This is how to build on it well, with code that runs on the version your farm actually accepts.

pH7x Systems® · · 22 min read

Subscription Edition is supported until at least 31 December 2035, under the Modern Lifecycle Policy, with no planned end of support. That is a longer runway than most software you will write this year.

Its SharePoint Framework ceiling, on the other hand, has not moved since 2023.

Those two facts together are the entire job. You are building something that has to keep working for a decade, on a framework version that will probably not change underneath you. That is an unusual position, and it rewards a particular kind of care.

Know your ceiling before you write anything

On-premises SharePoint runs only the SPFx versions matching its server-side dependencies. The compatibility reference gives Subscription Edition v1.0 to v1.5, and the 23H1 feature update added support for SPFx 1.5.1, described at the time as "one step on our long-term journey to improve and expand the capabilities of SharePoint Framework in SharePoint Server Subscription Edition".

The next update, 23H2, went one step further and added support for React 16 and Office UI Fabric React 7 in SPFx solutions. After that, nothing: reading 24H1 through 26H1, none of them mentions SPFx at all. The 26H1 update contains Modern People Card, Document Intelligence and a People Picker configuration option. Nothing about the framework.

You will also find community posts claiming a considerably higher ceiling. We could not verify any of them against a first-party source, so we are not going to repeat a version number we cannot stand behind.

What follows from that is a practical instruction rather than a version number: check the farm, not the article. Deploy a trivial package built at the version you intend to use, to your own app catalog, and see whether it loads. Fifteen minutes of that is worth more than any table, including the one above, because the ceiling is a property of your build.

Whatever you find, plan on it staying still. Design as though the version you have is the version you keep.

The environment that actually builds

The first trap is the toolchain, and it catches almost everyone once.

The current Yeoman generator will not help you. From version 1.13.0 it only produces SharePoint Online projects. Install the newest one and the on-premises target simply is not offered. You need a generator old enough to still ask:

bash
npm install gulp-cli@2.3.0 --global
npm install yo@2.0.6 --global
npm install @microsoft/generator-sharepoint@1.10.0 --global

Then pick the on-premises target in the generator flow.

Node is old. SPFx 1.4.1 and 1.5.1 target Node 6 and 8 by default. The documentation notes they can be made to work on Node v12.18.1, v14.17.1 and v16.15.0 with two changes: set graceful-fs to v4 or later, and replace node-sass with sass. That is the difference between a build machine somebody can still stand up in 2026 and one they cannot.

Record all of it in the repository. The generator version, the Node version, the gulp-cli version. In three years the person picking this up will not guess, and there will be no article to tell them. An .nvmrc and a short CONTRIBUTING section cost nothing now and save a week later.

Plan for the disconnected case. Scaffolding needs npm access. If your development machines cannot reach the registry, you need a local one, and Microsoft's own guidance is blunt about the cost: more software and a significant amount of work to maintain. Budget for it honestly rather than discovering it in week three.

The build commands are the gulp ones, which is the one place where the older articles are still correct:

bash
gulp serve
gulp bundle --ship
gulp package-solution --ship

Talking to SharePoint

SPHttpClient is the route. It handles the request digest and the authentication, and it behaves the same across every SPFx version, which makes it the most durable thing in the toolbox.

Put it in a service. Not in a component.

typescript
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';

export interface IDocument {
  Id: number;
  Title: string;
  Modified: string;
}

// Built apart from the call that uses it, so it can be exercised without a
// client, a farm or a browser. It is also the line most likely to be wrong.
export function itemsUrl(webUrl: string, listTitle: string, top: number): string {
  // A list called "HR & Payroll" or "Q1 'draft' items" breaks this. The test
  // list is always called Documents, which is why it reaches production.
  var list = encodeURIComponent(listTitle.replace(/'/g, "''"));

  return webUrl + "/_api/web/lists/getbytitle('" + list + "')/items" +
    '?$select=Id,Title,Modified' +
    '&$orderby=Modified desc' +
    '&$top=' + top;
}

// One place decides what an HTTP status means. Components never see numbers.
// Not called describe, because mocha puts a global function of that name in
// every test file, and the collision confuses long before it fails.
export function describeStatus(status: number): string {
  if (status === 401) { return 'notAuthenticated'; }
  if (status === 403) { return 'accessDenied'; }
  if (status === 404) { return 'notFound'; }
  if (status === 429 || status === 503) { return 'throttled'; }
  return 'unknown';
}

export class DocumentService {
  constructor(
    private readonly client: SPHttpClient,
    private readonly webUrl: string
  ) {}

  public getRecent(listTitle: string, top: number): Promise<IDocument[]> {
    return this.client
      .get(itemsUrl(this.webUrl, listTitle, top), SPHttpClient.configurations.v1)
      .then(function (response: SPHttpClientResponse): Promise<any> {
        if (!response.ok) {
          return Promise.reject(describeStatus(response.status));
        }
        return response.json();
      })
      .then(function (json: { value: IDocument[] }): IDocument[] {
        return json.value;
      });
  }
}

Four details in there matter more than they look.

The URL is built by a function of its own. That is not tidiness: it is the only part of the service that can be tested without a farm, and the next section leans on it.

The single quote is doubled before the title is encoded, and the order matters. SharePoint's REST syntax puts the list title inside single quotes, so a list called Q1 'draft' items closes the string early; OData escapes a quote by doubling it. encodeURIComponent deliberately leaves the apostrophe alone, so doubling afterwards would be too late.

$select is explicit. Asking for everything and using three fields is bandwidth you pay for on every render, and on a farm serving a few thousand people that is not free.

The promise chain is not nostalgia. TypeScript 2.4 compiling to ES5 will not give you async and await without a generator runtime. The chain is the version that always compiles.

If you want a friendlier API, PnPjs works, but you must pin to a version that supports your SPFx and treat that pin as permanent. An npm update that quietly moves you forward produces a solution that builds on your machine and fails on the server, which is the worst class of defect to debug.

Components, and which React you actually get

The documentation disagrees with itself here, and it is worth resolving rather than working around.

Three first-party sources point the same way. The SPFx platform compatibility table gives Subscription Edition v1.0 to v1.5. The 23H1 release note adds support for SPFx 1.5.1. The 23H2 release note adds support for React 16 and Office UI Fabric React 7, "allowing developers to utilize these newer component versions in their SharePoint Framework solutions".

One page points the other way. The SPFx developer article for on-premises still states that Subscription Edition "has all the same dependencies and requirements for the SharePoint Framework as SharePoint Server 2019", and sends you to v1.4.1. That page has not caught up: it contradicts the compatibility table published on the same site, and it describes the product as it was before 23H1. Two current sources against one stale one is not a tie, so the release notes are the answer.

What follows from that is concrete. Hooks arrived in React 16.8. On a farm at 23H2 or later, React 16 is supported, so hooks are available to you as long as you install React 16.8 or higher and pin it. The release note names the major version and not the minor, which is exactly the reason to pin it and to record the pin.

Below 23H2, and on SharePoint 2019 and 2016, you are on React 15 and every component is a class.

The class component is worth having either way, because it is the one that runs everywhere: at 1.4.1 and at 1.5.1, above and below 23H2. If you are writing something that has to outlive an update schedule you do not control, this is the floor, and it costs three things you have to do by hand.

typescript
import * as React from 'react';
import { DocumentService, IDocument } from '../services/DocumentService';

export interface IRecentDocumentsProps {
  service: DocumentService;
  listTitle: string;
  top: number;
}

export interface IRecentDocumentsState {
  status: string;
  items: IDocument[];
}

export default class RecentDocuments extends React.Component<
  IRecentDocumentsProps,
  IRecentDocumentsState
> {
  private _mounted: boolean = false;
  private _request: number = 0;

  constructor(props: IRecentDocumentsProps) {
    super(props);
    this.state = { status: 'loading', items: [] };
  }

  public componentDidMount(): void {
    this._mounted = true;
    this._load();
  }

  public componentWillUnmount(): void {
    // There is no cleanup function to return here. Without this flag, a slow
    // list on a busy farm updates a component that is already gone every time
    // somebody navigates away mid-load.
    this._mounted = false;
  }

  public componentDidUpdate(previous: IRecentDocumentsProps): void {
    // Compare first. Reloading unconditionally is an infinite loop: load, set
    // state, re-render, load again. It is silent until somebody opens the
    // network tab.
    if (previous.listTitle !== this.props.listTitle || previous.top !== this.props.top) {
      this._load();
    }
  }

  private _load(): void {
    var self = this;
    var request = ++this._request;

    this.setState({ status: 'loading', items: [] });

    this.props.service
      .getRecent(this.props.listTitle, this.props.top)
      .then(function (items: IDocument[]): void {
        // A slower answer to an older request must never overwrite a newer one.
        if (!self._mounted || request !== self._request) { return; }
        self.setState({ status: items.length ? 'ready' : 'empty', items: items });
      })
      .catch(function (failure: string): void {
        if (!self._mounted || request !== self._request) { return; }
        self.setState({
          status: failure === 'accessDenied' ? 'accessDenied' : 'error',
          items: []
        });
      });
  }

  public render(): React.ReactElement<IRecentDocumentsProps> {
    if (this.state.status === 'loading') {
      return <p role="status">Loading recent documents.</p>;
    }
    if (this.state.status === 'accessDenied') {
      return (
        <p role="alert">
          You do not have access to this library. Ask the site owner for read access.
        </p>
      );
    }
    if (this.state.status === 'error') {
      return <p role="alert">The library could not be reached. Try again in a moment.</p>;
    }
    if (this.state.status === 'empty') {
      return <p>No documents have been added yet.</p>;
    }

    return (
      <ul>
        {this.state.items.map(function (item: IDocument): JSX.Element {
          return <li key={item.Id}>{item.Title}</li>;
        })}
      </ul>
    );
  }
}

The _mounted flag replaces the cleanup function a modern hook would return.

The _request counter replaces everything you would otherwise reach for to handle concurrency. Two property pane changes in quick succession start two requests, and they come back in whatever order the farm decides. Without the counter, the older answer can land last and win.

The componentDidUpdate comparison is the classic class-component mistake. Omit it and you have built a loop against your own farm.

Theming

There is no ThemeProvider service at this version and no Fluent UI v9. What you have is theme tokens in SCSS, and they are enough:

scss
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';

.recentDocuments {
  color: "[theme: bodyText, default: #333333]";
  background-color: "[theme: white, default: #ffffff]";
  padding: 12px;
}

.recentDocuments a {
  color: "[theme: themePrimary, default: #0078d4]";
}

.recentDocuments a:hover {
  color: "[theme: themeDarkAlt, default: #106ebe]";
}

.recentDocuments .meta {
  color: "[theme: neutralSecondary, default: #666666]";
}

The rule is the same one that applies on any platform and any version: the solution takes its colours from the site. Write a hex value directly and you have built the thing that breaks the day somebody applies a different theme, and on a farm that serves several departments with several themes, that day is soon.

The default: value is what renders where no theme is applied. Choose it so that the web part is still readable rather than invisible.

Permissions are user experience

A failure is not one thing, and on-premises this matters more than people expect, because permissions in a farm with inherited structures and audience targeting are genuinely complicated.

Five situations, and a person can act on four of them:

  • Not signed in. Refresh and sign in again.
  • Access denied. Ask the site owner. This is not an error the reader caused, and it deserves an instruction rather than an apology.
  • Not found. The list was renamed or deleted. Say which one you were looking for.
  • Throttled or unavailable. The farm is busy. Offer a way to try again.
  • Nothing there. The query worked and the answer is empty. That is not a failure at all.

Map them once, in the service, as the code above does. Then design each one. A web part that says "An error occurred" for all five is a web part that generates a support ticket for every one of them.

What about Microsoft Graph

This question always comes back, and the usual dismissal, that your data is on your servers so Graph is irrelevant, is too glib. Plenty of farms sit next to a Microsoft 365 tenant, and wanting profile data from it is entirely reasonable.

The specific answer is more useful. MSGraphClient and AadHttpClient were both introduced in SPFx 1.4.1, so the classes exist at your version. Two things stand in the way.

The 1.4.1 release notes describe them as developer preview, "available for preview usage within SharePoint Online", and explicitly "not meant to be used in production yet".

More decisively, the permission model they depend on is SharePoint Online infrastructure from end to end. You declare webApiPermissionRequests in package-solution.json; deploying to the app catalog creates permission requests; an administrator approves them on the API access page of the SharePoint admin center; the grant is stored on the SharePoint Online Client Extensibility application that Microsoft provisions in every Entra ID. A farm of your own has none of those things, so there is nothing to request from and nothing to grant to.

The obvious workaround is closed as well: the documentation states that using the Microsoft Authentication Library directly with SPFx is not supported from v1.4.1 onwards.

To be precise about what is established and what is not: what is documented is that the clients were preview and scoped to SharePoint Online, that their permission mechanism is a SharePoint Online service, and that MSAL is unsupported. What is not documented anywhere we could find is a supported on-premises route. Absence of documentation is not proof that nothing works, but a production solution on an undocumented path, in a farm you have to patch for the next nine years, is a decision to make with open eyes.

The pattern that does hold up is a service you own. An API deployed where you control it, secured however you choose, which talks to Graph server-side and which your web part calls with SPHttpClient or a plain HttpClient. Token handling stops being the browser's problem, and the solution stays inside what is supported.

Testing: the runner is already in the box

It is usually assumed that there is no test runner here. Microsoft's own CI/CD article is where the assumption comes from, and it is worth reading the sentence carefully: "The SharePoint Framework doesn't provide a testing framework by default (since 1.8.0)".

Since 1.8.0. Below that, it does, and the version Subscription Edition accepts is below that.

At 1.5.1 the build package @microsoft/sp-build-web depends on @microsoft/gulp-core-build-karma, which brings Karma, Mocha, Chai, Sinon, sinon-chai, karma-coverage, the Istanbul instrumenter and PhantomJS. The generator puts @types/chai and @types/mocha into the project it writes for you. There is a gulp test task waiting, documented as "runs unit tests, if available".

What is missing is not the machinery. It is one config file, and any tests at all. Nobody tells you that, so gulp test looks broken and everybody concludes there is nothing there.

Three things nothing tells you

The task does nothing until a config file exists. It looks for ./karma.config.js in the project root. If the file is absent it logs a warning and returns, and the build stays green. Write it once:

bash
gulp test --initkarma

That copies the default config into your project, and from then on gulp test actually runs.

The runner reads compiled output, not your sources. The default match is /.+\.test\.js?$/ applied to the lib folder. The task generates temp/tests.js containing a webpack require.context over lib, and Karma loads that single file. So a test you write at src/webparts/recentDocuments/services/DocumentService.test.ts is found as lib/webparts/recentDocuments/services/DocumentService.test.js once TypeScript has run. Two consequences: name the file .test.ts and nothing else (a .spec.ts is silently ignored, with no error and no warning), and remember that a stale lib folder runs stale tests. gulp clean when a result surprises you.

Failing tests do not fail the build. failBuildOnErrors defaults to false, so a red test logs a warning and the build carries on. Production mode is the exception: with --ship the failure stops it. That default is worth changing on day one, because a warning in a wall of build output is a test suite nobody reads. In gulpfile.js, before build.initialize(gulp):

javascript
build.karma.setConfig({ failBuildOnErrors: true });

The test

Karma loads the mocha and sinon-chai frameworks, so describe and it are global and Chai is there to import. Nothing needs a renderer, because nothing worth testing here renders.

typescript
// src/webparts/recentDocuments/services/DocumentService.test.ts
import { expect } from 'chai';
import { itemsUrl, describeStatus } from './DocumentService';

describe('itemsUrl', () => {
  // Everybody develops against a list called Documents. Production has
  // "HR & Payroll" and "Q1 'draft' items", and that is where this breaks.
  it('doubles a single quote, which OData would read as the end of the title', () => {
    expect(itemsUrl('https://sp/sites/hr', "Q1 'draft' items", 5))
      .to.contain("getbytitle('Q1%20''draft''%20items')");
  });

  it('encodes an ampersand, which would otherwise start a new parameter', () => {
    expect(itemsUrl('https://sp/sites/hr', 'HR & Payroll', 5))
      .to.contain("getbytitle('HR%20%26%20Payroll')");
  });

  it('asks only for the three fields it renders', () => {
    expect(itemsUrl('https://sp', 'Documents', 5))
      .to.contain('$select=Id,Title,Modified');
  });
});

describe('describeStatus', () => {
  it('separates the failures a person can act on', () => {
    expect(describeStatus(401)).to.equal('notAuthenticated');
    expect(describeStatus(403)).to.equal('accessDenied');
    expect(describeStatus(404)).to.equal('notFound');
  });

  it('reads a busy farm as throttled rather than broken', () => {
    expect(describeStatus(429)).to.equal('throttled');
    expect(describeStatus(503)).to.equal('throttled');
  });

  it('does not pretend to recognise a status it has never seen', () => {
    expect(describeStatus(500)).to.equal('unknown');
  });
});
bash
gulp test                     # once, with coverage
gulp test --match itemsUrl    # passed through to mocha as a grep
gulp test --debug             # leaves the browser open, skips instrumentation

PhantomJS, which is the part that will actually stop you

The default config lists exactly one browser, and it is PhantomJS, whose development was suspended in 2018.

phantomjs-prebuilt downloads a binary at install time from a table with four entries: linux x64, linux ia32, darwin and win32. Anything else gets "there is no binary available for your platform/architecture" and the install fails. Note what darwin means there, because it is the part that reads as working and is not: there is a single macOS build and it is x86_64, so on Apple Silicon the download succeeds and you get an Intel binary that needs Rosetta. On Linux arm64 there is no entry at all.

The download host is a GitHub release, and it is overridable:

bash
PHANTOMJS_CDNURL=https://your-mirror/phantomjs npm install

That is the same disconnected-machines problem from earlier in this article, and it has the same shape: one more thing to mirror, budgeted for honestly rather than discovered on the morning of the build.

If PhantomJS is not viable for you, and after 2018 it usually is not, change the browser, not the framework. Mocha, Chai and every test above stay exactly as they are; only the launcher in karma.config.js changes, to karma-chrome-launcher with ChromeHeadless. Be aware that Karma is at 0.13 here, so which launcher version cooperates is something you find by trying, not by reading. That is a contained afternoon and it does not touch a line of your test code.

The other route, if you would rather leave the browser out of it entirely, is Jest, which Microsoft's CI/CD article points at directly and which is where SPFx went anyway from 1.8.0. For a React 15 project that documentation names the preset: @voitanos/jest-preset-spfx-react15. It runs in Node, so PhantomJS stops being your problem, at the cost of a second toolchain sitting beside gulp test.

What to test, and what not to

Earns its place:

  • URL construction. An apostrophe, an ampersand, a space, an accent. The highest-value test in the whole solution, and it is the three cases above.
  • Status mapping. A 403 becomes a different state from a 500, and a 404 from both.
  • Parsing. A missing field, a date that will not parse, an empty list.

Does not earn its place: asserting that React rendered a <ul>.

And one discipline that matters more than any of it: break each guard on purpose and check that a test goes red. Delete the _mounted flag, or the componentDidUpdate comparison, and run the suite. If everything still passes, you have learned something real: either that guard is untested, or the fixture is not exercising it, or the test is measuring the wrong property. A green suite that stays green when you sabotage the code is not telling you the code is right. It is telling you nothing at all.

Accessibility

React 15 renders the same HTML as React 19. Nothing here depends on your version.

  • A list of results is a <ul> of <li>, not a stack of <div>.
  • The link carries the title, so somebody navigating by links hears document names rather than "read more" repeated.
  • Status changes go in a live region, which is what role="status" and role="alert" in the component above are doing.
  • Every control is reachable by keyboard and focus is visible.
  • Nothing is carried by colour alone. A status needs a word.

Verify it in the browser's accessibility tree rather than by eye. What a screen reader reads is not always what the markup suggests.

Fewer dependencies, and why it matters more here

Every library you add is pinned to an old TypeScript and an old React whether you like it or not, and it stays pinned. On a tenant you can upgrade quietly; on a farm an upgrade window is a change request with a maintenance weekend attached.

Before adding anything, ask whether SharePoint already does it. A chart is often better as a number with a proportional bar. A PDF is usually better as the browser's own print with a print stylesheet, which also produces selectable text.

The measurement that settles it is the package size on a clean build, before and after.

Keep the property pane boring

Two questions decide whether a setting belongs there.

Does the person who adds the web part set it once, or does the reader change it while using it? If the second, it belongs in the interface.

Does it change behaviour? If a property is accepted and ignored, it is not a placeholder for a future release. It is a defect that somebody will report, having trusted it.

Four properties that all do something beat twelve that mostly do not, on any version of anything.

Deployment

The app catalog on your own farm, and the workbench at /_layouts/workbench.aspx on a site you control. That workbench is not the hosted one that is being retired for SharePoint Online; yours lives as long as the farm does.

Two things worth building into the routine from the start. Version the .sppkg deliberately in package-solution.json rather than leaving it at 1.0.0.0 forever, because an app catalog that shows the same version for three different builds is a support problem waiting to happen. And keep a record of which build is deployed where, since there is no service telling you.

The summary

A long runway on a still stack is not a bad place to be. It rewards decisions that are cheap now and expensive later: put the calls in a service, map errors once, take colours from the site, guard your state updates, test the pure parts, add as little as you can.

A web part written that way at 1.5.1 will still be working when the platform's support window closes in 2035. One written the other way will not survive its first theme change, and you will be the one maintaining it either way.

Before you start

  • Confirm the farm's feature update level and verify the SPFx ceiling by deploying a trivial package, rather than trusting a table
  • Check whether the farm is at 23H2 or later, because that is what decides whether React 16 and hooks are available, and pin the React version either way
  • Pin and record the generator, Node, gulp-cli and Yeoman versions in the repository
  • Plan the npm access question before day one if the machines are disconnected
  • Put every call in a service, and map HTTP statuses to states in one place
  • Escape single quotes and encode list titles in every REST URL
  • Guard state updates with a mounted flag and a request counter
  • Compare props before reloading in componentDidUpdate
  • Use theme tokens in SCSS, never a hex value
  • Design a state for each failure, including the empty one
  • Run gulp test --initkarma once, name test files .test.ts, and set failBuildOnErrors: true
  • Settle the PhantomJS question before writing tests, by swapping the launcher or by moving to Jest
  • Test URL construction, status mapping and parsing, and break each guard to prove the test goes red
  • Pin PnPjs to a version that supports your SPFx, and treat the pin as permanent
  • Version the package deliberately and record what is deployed where

Keep reading