Collaborate, Innovate, Automate

Building a Move Page Command Set for SharePoint Site Pages with SPFx

The Site Pages library supports folders, and on any reasonably large Intranet that is maintained by various groups, they are used. They provide security options as well as structure. As part of this picture teams are also faced with a default mechanism that saves new pages to the root of the library. What Site Pages doesn't give you is a way to move a page from one folder to another. There's no Move to on the command bar for pages, the way there is for documents, so pages are essentially stuck unless you want to Copy to and then return and delete the original, thereby also ensuring that the ID count of your site increases.

Another common workaround is a Power Automate flow. Create and tag the page, then wait for a flow to trigger and move the page. This is better, but it still takes the content creator out of their workflow. They wait. They refresh the page. They wait some more. Only once the page has been moved can they wire it into whatever navigation is required, or update links to it from other locations. This project builds a small SPFx ListView Command Set that adds a Move page command directly to the Site Pages command bar, allowing the author to move the page and carry on with their workflow.

It's a small thing. But it's the kind of small thing a content team notices every single day.

Prerequisite: This page assumes a working SPFx development setup and an app catalog you can deploy to. The command set itself is a standard ListViewCommandSet extension, scoped to the Site Pages library by the package itself.

What This Adds

Site Pages command bar showing the Move page command with a folder picker dialog open

Step 1: A Command That Only Appears When It Makes Sense

The command is a single entry in the command set's manifest, MOVE_PAGE, titled Move page. On its own, that would put a Move page button in the command bar of every view, whether anything was selected or not. So the first thing onInit does is hide it, then subscribe to the list view's listViewStateChangedEvent, which fires whenever the view's state changes, selection included.

The rule is simple: at least one row selected, and none of them a folder. An author can select several pages and move them together, but the moment a folder is anywhere in the selection, the command disappears. Folders are identified by FSObjType, which is 1 for a folder and 0 for a file.

private static _isFolder(row: RowAccessor): boolean {
  return String(row.getValueByName('FSObjType')) === '1';
}

private _onListViewStateChanged = (_args: ListViewStateChangedEventArgs): void => {
  const moveCommand: Command = this.tryGetCommand('MOVE_PAGE');
  if (moveCommand) {
    const selectedRows = this.context.listView.selectedRows ?? [];
    moveCommand.visible =
      selectedRows.length >= 1 && !selectedRows.some(MoveButtonCommandSet._isFolder);
  }
  this.raiseOnChange();
}

In practice, SharePoint places it in the overflow menu alongside Automate once the command bar runs out of room, which is exactly where an author would go looking for it.

Step 2: An Icon That Matches the Site

Command set icons are set through iconImageUrl, an image URL or data URI, which means out of the box they're static. Hard-code a grey SVG and it looks fine on one site, then sits there in the wrong colour on the next one with a different theme, instantly marking it out as the custom button among the built-in ones.

Instead, the icon is built at runtime. When the command set initialises, it reads the current site's theme, drops the relevant colour into an SVG, and assigns the result to the command as a data URI. The same package, deployed to any site, picks up that site's colours automatically.

public onInit(): Promise<void> {
  const moveCommand: Command = this.tryGetCommand('MOVE_PAGE');
  if (moveCommand) moveCommand.visible = false;

  // Wait for the service scope before consuming ThemeProvider,
  // otherwise tryGetTheme() can still come back undefined
  this.context.serviceScope.whenFinished(() => {
    this._themeProvider = this.context.serviceScope.consume(ThemeProvider.serviceKey);
    this._applyIconTheme(this._themeProvider.tryGetTheme());
    this._themeProvider.themeChangedEvent.add(this, this._onThemeChanged);
  });

  this.context.listView.listViewStateChangedEvent.add(this, this._onListViewStateChanged);
  return Promise.resolve();
}

private _applyIconTheme(theme: IReadonlyTheme | undefined): void {
  const moveCommand: Command = this.tryGetCommand('MOVE_PAGE');
  if (!moveCommand) return;

  const fillColor = theme?.palette?.themePrimary ?? DEFAULT_ICON_FILL;
  moveCommand.iconImageUrl = getMoveIconDataUri(fillColor);
  this.raiseOnChange();
}

The whenFinished wrapper is the part that's easy to miss. Consume ThemeProvider too early and it can hand back a provider with no theme yet, so the icon falls back to its default colour and, because no theme change ever fires, stays that way. Waiting for the service scope avoids that entirely.

The colour itself comes from palette.themePrimary, the site's accent colour, which genuinely changes from theme to theme. The obvious alternative, semanticColors.bodyText, stays a near-identical neutral grey across almost every built-in theme, so it wouldn't reflect the site at all. Subscribing to themeChangedEvent means that if the site owner changes the theme, the icon follows without a reload.

The result is a command that looks like it belongs, which matters more than it sounds. Authors trust buttons that look native.

Step 3: Picking a Folder and Moving the Page

Clicking Move page opens a small React dialog, MoveFolderPicker, listing the folders in the Site Pages library. The author picks one, confirms, and that's it. Choosing from a list rather than typing a path means there's no way to send a page to a folder that doesn't exist.

onExecute gathers the selected pages, skipping any folders just in case, and mounts the dialog into its own container on the page:

const selectedFiles = selectedRows
  .filter(row => !MoveButtonCommandSet._isFolder(row))
  .map(row => ({
    name: row.getValueByName('FileLeafRef') as string,
    serverRelativeUrl: row.getValueByName('FileRef') as string
  }));

const service = new SitePagesService(
  this.context.spHttpClient,
  this.context.pageContext.web.absoluteUrl,
  list.serverRelativeUrl
);

const element = React.createElement(MoveFolderPicker, {
  service,
  libraryTitle: list.title,
  selectedFiles,
  onDismiss: () => this._closePicker(),
  onMoveComplete: () => {
    this._closePicker();
    window.location.reload();
  }
});

ReactDOM.render(element, this._container);

The move itself lives in a small SitePagesService, which calls the SharePoint REST API through SPFx's own spHttpClient, no PnP JS dependency needed, moving each selected file from its current server-relative URL into the chosen folder:

public async movePage(sourceUrl: string, targetFolderUrl: string, fileName: string): Promise<void> {
  const origin = new URL(this.webUrl).origin;

  const response: SPHttpClientResponse = await this.spHttpClient.post(
    `${this.webUrl}/_api/SP.MoveCopyUtil.MoveFileByPath(overwrite=@a1)?@a1=false`,
    SPHttpClient.configurations.v1,
    {
      body: JSON.stringify({
        srcPath: { DecodedUrl: `${origin}${sourceUrl}` },
        destPath: { DecodedUrl: `${origin}${targetFolderUrl}/${fileName}` },
        options: { KeepBoth: false, ResetAuthorAndCreatedOnCopy: false, ShouldBypassSharedLocks: false }
      })
    }
  );

  if (!response.ok) throw new Error(await SitePagesService._errorMessage(response));
}

Overwriting is switched off, so if a page with the same name already exists in the destination, the move fails with SharePoint's own message rather than quietly replacing it. The same goes for a page someone else is editing, the lock is respected, not bypassed.

Once the move completes, the dialog closes and the library reloads, with every moved page sitting in its new folder. No flow run to check on, no guessing whether it's happened yet.

Because the selection can hold more than one page, tidying up a batch, a handful of pages created in the library root that really belong in a department folder, say, is one move rather than several.

Important: moving a page changes its URL, because the folder is part of the path. Anything that already points at the old URL, a navigation link, a Quick Link, a link in another page, needs updating. This is exactly why moving the page first, then wiring it up, matters.

Step 4: Deployment, No Registration Script Required

A lot of command set write-ups end with a PnP PowerShell script that registers a custom action on each site (there might even be one in the scripts section of this site). This one doesn't need it. The registration travels inside the .sppkg itself, so deploying the package is the whole job.

It's the standard SPFx approach: the extension's registration ships in the package's sharepoint/assets folder, rather than being bolted on afterwards with a separate script.

The package targets list template 119, Site Pages, so the command only ever turns up in Site Pages libraries, never in document libraries or anywhere else it doesn't belong.

The End Result

A missing piece of the Site Pages library, filled in with the smallest possible amount of custom code. An author creates a page, realises it belongs somewhere else, selects it, moves it, and goes straight on to adding it to navigation, all in the same few minutes, in the same place they were already working.

That's the real difference from the Power Automate route. A flow can move a page just as well, but it moves it later, on its own schedule, and the author is left waiting for something they can't see. The command set moves it now, while the author is still in context, and the new URL is final the moment they need it.

Not every useful piece of SPFx has to be a big build. Sometimes it's just one button, in the right place, that looks like it was always there.

Related Reading

Ready to deploy as-is, or fully customisable to match your organisation's brand guidelines. Contact us to discuss your requirements.