Exploring the Microsoft Graph SDK: What’s Under the Hood

Microsoft Graph has become the backbone of modern Microsoft 365 development, but how you interact with it can make a big difference in your productivity, app performance, and code maintainability. Microsoft’s official SDK for working with Graph, @microsoft/msgraph-sdk, is a TypeScript-based library that offers a consistent, fluent API surface for interacting with Microsoft Graph endpoints across services like Users, Drives, and Mail.

Recently, there was an episode on the Code.Deploy.Go Live. podcast with Julie Turner and Andrew Connell, which discussed some initial concerns with the Microsoft Graph SDK. So, I decided I would spend more time with the SDK to understand how it’s designed, how it performs in real-world usage, and what developers can expect when building modern web applications with it.

If you just want the conclusions, scroll down to Investigation: What I Found. Otherwise, let’s get hands-on.

Getting Hands-On

To get a feel for the SDK, I built a simple SharePoint Framework (SPFx) web part that fetches a list of users from Microsoft Graph. The goal was to evaluate both the developer experience and the architectural trade-offs involved.

Using @microsoft/msgraph-sdk

Initial Configuration

To start, I am going to import an InteractiveBrowserCredential, this is because I am building a Single Page Application.

Calling into APIs

I need to install the API package that I want to be working this. In this case, I wanted to work with Users.

npm install @microsoft/msgraph-sdk-users

Setting it up

Next I will import a client and a RequestAdapter from the core @microsoft/msgraph-sdk package and the API specific package for working with Users.

Once these have been imported, I can now create new instances of a GraphRequestAdapter and the ServiceClient using the authProvider I created earlier.

Now that this is completed, I can start working on calling the APIs using that same graphServiceClient.

And voila, I’m done. The coding experience is nice. The SDK is fluent and logical, the IntelliSense is handy for any developer.

Investigation: What I found

At first glance, the SDK provides everything you’d expect… structured setup and very good intellisense. However, after testing and examining the build output more closely, I noticed a few concerns.


Bundle Size Considerations

One of the first things that stood out during testing was the bundle size, which Julie and Andrew discussed in their podcast. Even for relatively simple Graph operations (like fetching users), the resulting bundle was noticeably larger than expected for front-end applications.

To put this into perspective, to do a /users call in PnPjs (another popular Microsoft Graph SDK), the resulting bundle size is 1/10th the size, or 262KB.

This comes down to how the Microsoft Graph SDK is architected. The Microsoft Graph SDK is built on top of Kiota, Microsoft’s code generation framework. Kiota provides:

  • A core runtime (@microsoft/kiota-http-fetch, @microsoft/kiota-abstractions, etc.) to handle request building, serialization, and response parsing.
  • Authentication adapters (@microsoft/kiota-authentication-azure, @azure/identity) that integrate with Microsoft Entra and Azure Identity flows.
  • Generated API modules (like @microsoft/msgraph-sdk-users), which import the full endpoint surface area.

Code generation tools come in many flavors and patterns, and many of them fall victim to the same issues… a codebase that is bloated, heavy and inflexible. That’s not to say the Microsoft Graph SDK is all of these things, in fact, Microsoft did a fantastic job making sure the SDK is opinionated where it matters, but still flexible to allow developers to build and extend it. However, if you peak into the codebase itself, you’ll start to notice a pattern…. layers upon layers of abstraction and significant amounts of duplicate code.

Each API endpoint is isolated and fully self-contained. This means each endpoint implements it’s own request builder, including it’s own query parameter mappings, error mappings, serialization logic, and full request metadata for GET/POST/{} operations.

If you take a look at this code which is for the /users/{id}/messages endpoint. You have a full request builder which defines the entire endpoint. It defines what metadata exists on a request, which OData operators are supported, etc. Every endpoint will have the same exact structure, even if, for example, the same QueryParameters exist on that other endpoint. It will be duplicated.

It took me nearly 3 minutes to bundle my SPFx solution when I added the @microsoft/msgraph-sdk-users package. 🥺

This is a big reason for the large bundle sizes. The SDK is very verbose if you’re going to use it as-is, however, this can be improved. Which leads me to the next part….


A “convenience” SDK and a different approach to SDK consumption

I posted my initial thoughts of this blog into a LinkedIn post. Sebastien Levert (Microsoft Graph) replied to my post and said some things that I thought were interesting, but also initially concerning.

He stated:

Kiota supports native endpoint selection to build your own API clients. The “Graph SDK” is provided for convenience and will only bundle the packages you really need (we split them across 70+ packages). So you have this opportunity to split at the operation level, the base routes and to include all of it. I’m happy to have a deeper chat on our design choices!

That being said, I understand it’s a big shift from the previous version and we are conscious there will be a transition period. We had really good results with other languages and think this approach is much more repeatable and reduce significantly the efforts required to maintain all languages vs. having special approaches per language.


As I read this, I don’t think he disagreed with anything I stated, but he did supply some really helpful foundational information into the reasoning for the way things are built as they are. (If you’re interested in learning more Darrel Miller did a great session on this, you can watch on Youtube).

Split at the operation level

Remember when I said that each API endpoint is isolated and fully self-contained? This architecture was intentional. The way the SDK is built (separate and fully encapsulated RequestBuilders), allows Kiota to generate versions of the Microsoft SDK using only the individual endpoints you wish to use.

That is, if you find the bundle sizes too large from the 70+ npm packages Microsoft ships for convenience, you can actually create your own using Kiota and include only the endpoints you want. This will greatly reduce the overall bundle sizes of your solution.

In fact, this is the recommendation by Microsoft. The Microsoft Graph API is HUGE. While I personally believe there is a lot of overhead and duplicate code due to Kiota, a large part of the problem of shipping a feature rich SDK on Graph, is that there are just a lot of endpoints to include. In PnPjs, we solve this problem with selective imports.

If you’re looking to learn how about to use Kiota to generate your own SDK, check out Vincent Biret’s demo.

Convenience?

This leads into my final concern. Convenience. Now, I’m taking the term convenience too literally, I know this. Convenience in the original context was “it’s there if you need to use it, but the recommendation is to use Kiota”. So bare with me as I stretch this out a bit…

The point of an SDK is to make it easier for developers to build on top of something that already exists. Without an SDK, you’re left wiring everything by hand and reading raw API docs. For many developers, installing the Microsoft Graph SDK from npm just works: they can call graphClient.users.get() and move on.

But here’s the catch: If you’re not careful, you’re inflating your bundle sizes without even realizing it. Which is why the Kiota approach, splitting at the operation level, exists. You can generate your own SDK tailored to your exact needs.

It sounds great in theory… but let’s be honest, how many developers will actually go through the process of generating a custom SDK to shave off the bundle size? It’s less “install and go” and more “install, generate, configure, integrate.”

So this sort of takes away from the convenience I normally associate with using an SDK… though, it’s still easier than writing everything yourself.


Closing thoughts

I want to be clear… this blog isn’t about knocking the Microsoft Graph SDK. It’s a solid, fully-supported library, and for enterprises that need official backing or can’t use third-party or open-source tooling, it’s a reliable choice. Kiota alone, is an impressive feat of engineering that makes creating your own SDK’s from OpenAPI defined APIs much easier than writing them yourself.

Ultimately, the takeaway is this: Microsoft’s Graph SDKs are designed to cover every scenario out-of-the-box, which is convenient but comes with trade-offs. Kiota gives you tools to optimize, but at the cost of some extra setup. If bundle size and control matter, generating a custom SDK with Kiota or looking towards other open-source SDKs is the way to go.

Hopefully this post was helpful. Maybe, if anything, it gives you some things to think about before installing any package from NPM (Microsoft Graph SDK or otherwise).

Series – Working with (Hidden) APIs that power SharePoint Knowledge Agents

Improve This Site Feature

With the rollout of Microsoft Copilot and the SharePoint Knowledge Agent, we’re starting to see more advanced AI-driven insights show up across modern SharePoint sites. One such set of features is under the “Improve this site” prompt, which surfaces recommendations like fixing broken links, retiring old pages, or identifying content gaps.

What most folks don’t realize is these suggestions are backed by hidden APIs (thanks Microsoft) that power the Knowledge Agent’s insights. In this post, I’ll walk you through how to interact with these APIs directly so you can integrate, debug, or automate them as needed.


One thing to understand early on: the SharePoint Knowledge Agent runs per site. That means each site collection evaluates and stores its own recommendations in isolation. If you’re an admin or developer looking to manage multiple sites across a hub or tenant, you’re going to be severely disappointed.

Let’s say you want to find all broken links across every marketing site in your org or retire outdated content from dozens of other sites. There’s no out-of-the-box way to do that centrally. Which, is really frustrating.

By using the APIs that power Knowledge Agent, you can build custom dashboards or admin tools that:

  • Query and aggregate missing link reports across multiple sites
  • Automate page retirement policies using Power Automate or Azure Functions
  • Surface content quality issues without requiring site owners to manually check each site

If you’ve ever built custom inventory tools for SharePoint before, this should feel familiar.

As always, these are undocumented APIs and their usage likely won’t have much support from Microsoft.


Prerequisites

Before diving in, make sure the following are true for your environment:

  1. You have Copilot licensing enabled.
  2. The EnsureMissingLinksFeature is activated. You can enable it with the following API call:
fetch("https://tenant.sharepoint.com/_api/sitemanager/EnsureMissingLinksListFeature", {
method: "POST",
headers: {
"accept": "application/json;odata=verbose",
"content-type": "application/json;odata=verbose",
"x-requestdigest": "<yourRequestDigest>"
},
credentials: "include"
});

Fix Broken Links

The Fix Broken Links functionality identifies links on your site that no longer resolve. This is a POST request that takes a payload with maxCount and snoozedLinks as parameters. The response should provide you with the top maxCount links in your site pages which are bad or invalid links!

fetch("https://tenant.sharepoint.com/_api/sitemanager/TopMissingLinks", {
  method: "POST",
  headers: {
    "accept": "application/json;odata=verbose",
    "content-type": "application/json;odata=verbose",
    "x-requestdigest": "<yourRequestDigest>"
  },
  body: JSON.stringify({
    maxCount: 6,
    snoozedLinks: ""
  }),
  credentials: "include"
});

Sample Response

{
  "d": {
    "TopMissingLinks": {
      "MissingLinks": {
        "results": []
      }
    }
  }
}

If there are broken links, they will appear in the results array.


Retire Inactive Pages

This feature identifies pages that haven’t seen much activity and lets you retire them. Retired pages are:

  • Deprioritized in search and Copilot experiences
  • Display a banner noting the content may be outdated

Get Retirable Pages

fetch("https://tenant.sharepoint.com/_api/sitemanager/RetirablePages", {
  method: "POST",
  headers: {
    "accept": "application/json;odata=verbose",
    "content-type": "application/json;odata=verbose",
    "x-requestdigest": "<yourRequestDigest>"
  },
  body: JSON.stringify({
    top: 6,
    isDebug: false,
    snoozedPaths: ""
  }),
  credentials: "include"
});

The response includes a list of page metadata. Each result includes properties like Title, Path, LastActivityTimestamp, and a thumbnail image.

If you’re curious where this property is stored… it’s tracked on the Site Pages library using a hidden column named _SPIsRetired.

How to Retire a Page

There are two main steps:

  1. Ensure the retire feature is enabled
  2. Set the _SPIsRetired field on the page to true

Step 1 – Enable Retire Page Feature

fetch("https://tenant.sharepoint.com/_api/sitemanager/EnsureRetirePageFeature", {
  method: "POST",
  headers: {
    "accept": "application/json;odata=verbose",
    "content-type": "application/json;odata=verbose",
    "x-requestdigest": "<yourRequestDigest>"
  },
  credentials: "include"
});

Step 2 – Update the Page Metadata

Use the ValidateUpdateListItem endpoint:

fetch("https://tenant.sharepoint.com/_api/web/getlistitemusingpath(DecodedUrl='/SitePages/WhoWeAre.aspx')/ValidateUpdateListItem", {
  method: "POST",
  headers: {
    "accept": "application/json;odata=verbose",
    "content-type": "application/json;odata=verbose",
    "x-requestdigest": "<yourRequestDigest>"
  },
  body: JSON.stringify({
    formValues: [
      {
        FieldName: "_SPIsRetired",
        FieldValue: "true"
      }
    ]
  }),
  credentials: "include"
});

Step 3 – Publish the Page

After updating the metadata, be sure to publish the page:

fetch("https://tenant.sharepoint.com/_api/web/getfilebyserverrelativepath(DecodedUrl='/SitePages/WhoWeAre.aspx')/Publish", {
  method: "POST",
  headers: {
    "accept": "application/json;odata=verbose",
    "x-requestdigest": "<yourRequestDigest>"
  },
  credentials: "include"
});

Once published, users visiting the page will see a warning banner indicating the page is no longer maintained.


Where to See All Retired Pages

There’s also a special view in the Site Pages library that lets you filter for retired content:

https://tenant.sharepoint.com/site/mysite/sitepages/forms/byauthor.aspx?viewid=af57819e-cb3e-443d-9517-b1ee5dd499d8


Final Thoughts

While most users only see the polished UI that Copilot and SharePoint surfaces, behind the scenes is a powerful and mostly hidden set of APIs driving these insights. I hope this post helped shed light on how these features actually work and how you can hook into them directly.

In future posts, I’ll explore how to surface Content Gaps across your sites using these same hidden APIs.

As mentioned, these are undocumented and YMMV if you are incorporating them into your own products.

Enable ratings on SharePoint Lists with the REST API

A question came up recently in one of my GitHub repositories where someone was wondering if you could enable ratings on a SharePoint list via the REST API.

After a bit of research, I found an undocumented (use this at your own risk!) API. The Microsoft API has an endpoint listed under the root _api folder named Microsoft.SharePoint.Portal.RatingSettings.SetListRating.

This endpoint requires a POST request and takes two parameters: listID and ratingType (1 = Star Ratings, 2=Likes)

Enable Ratings “Likes”


fetch("https://yourtenant.sharepoint.com/sites/yoursite/_api/Microsoft.SharePoint.Portal.RatingSettings.SetListRating?listID='6b847372-cf23-4bbf-87b1-72ca6c8a4bc1'&ratingType=2", {
  "headers": {
    "accept": "application/json",
    "accept-language": "en-US,en;q=0.9",
    "cache-control": "max-age=0",
    "content-type": "application/json;odata=verbose;charset=utf-8",
    "if-match": "*",
    "x-http-method": "MERGE",
    "x-requestdigest": "<YourRequestDigest>"
  },
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": null,
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
});

Enable Ratings “Star Ratings”


fetch("https://yourtenant.sharepoint.com/sites/yoursite/_api/Microsoft.SharePoint.Portal.RatingSettings.SetListRating?listID='6b847372-cf23-4bbf-87b1-72ca6c8a4bc1'&ratingType=1", {
  "headers": {
    "accept": "application/json",
    "accept-language": "en-US,en;q=0.9",
    "cache-control": "max-age=0",
    "content-type": "application/json;odata=verbose;charset=utf-8",
    "if-match": "*",
    "x-http-method": "MERGE",
    "x-requestdigest": "<YourRequestDigest>"
  },
  "body": null,
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
});

A quick reminder…

As mentioned previously, this is an undocumented API and I’ll probably get yelled at for promoting it! 🙂 So please, use this at your risk as you won’t be provided any support from Microsoft if you implement this.

Renewed as a Microsoft MVP for 2020-2021

It’s that time of the year… July 1st has arrived and the Microsoft MVP renewals have been completed! I am excited and honored to announce that I have been re-awarded Microsoft MVP for Office Apps and Services for a third time.

MVP

Once again, I’d like to thank Microsoft for continuing my involvement with the MVP program, and embracing the relationship between the platforms we use and the community that is heavily involved with it.

Recap: The Past year

The past year has been an awesome year for me, both personally and professionally. Each year I like to focus on new methods and ways in which I can interact with community — this past year was no exception.

Community Closeups

My good friend David Warner and myself wanted to start a new Podcast/Vodcast called Community Closeups. Community Closeups is a video series that highlights the unique and talented individuals in our community. It’s a laid back interview where we get personal and talk about what makes us unique — and of course, lots of SharePoint!

I would like to personally thank everyone who has been a guest on our show and sharing a little part of themselves with the rest of the community. As David always mentions, we know each other online through our photos and our avatars, but rarely do we get to learn  about the personal sides of the people we interact with daily. Thank you, from the bottom of our hearts for making this show a success!

Microsoft 365 PnP Team

This year I was/am honored to be invited as member of the Microsoft 365 PnP Team. The PnP Team is a collective of Microsoft Employees and community developers who work together to provide solutions, tools and guidance to the community through various open source initiatives. Over the last year I’ve had the pleasure of working on the PnP SharePoint Starter Kit and working in various GitHub Issues lists. It’s with great joy that I am able to help contribute to solutions and guidance that help developers adapt to the ever evolving landscape of Office 365.

Speaking Events

This past year was a really fun year for speaking. David Warner and myself teamed up and delivered a series of SPFx and PnP presentations across the US! We started in Salt Lake City, UT early in the spring and in fall we completed a “back-to-back-to-back”  weekend SharePoint Saturday tour starting in Los Angeles, followed by Boston and Denver! It was a great experience to share some of the tips and tricks for SharePoint Framework Development and meet all the extraordinary people in our community!

One of the coolest opportunities was being chosen to be a part of the Podcast center at Microsoft Ignite, where David and I hosted a Community Closeups episode with the great Vesa Juvonen!

Forums

One of the highlights for me every year is being a part of the rich community forums that we have. Whether you are a part of TechCommunity, Reddit SharePoint, Facebook SharePoint Groups, or any other forums, there is a limitless amount of knowledge sharing. The forums exist not only for asking for help and seeking guidance, but they are filled with community members sharing what they’ve learned, through blogs and videos and tutorials.

Looking Forward

2020 has started out to be a very interesting year. COVID-19 has introduced some difficulties in hosting in-person events, and as such, I likely won’t be speaking at too many conferences this year (unless virtual), but we are looking to try and host a SharePoint Saturday Event online at some point later this year or early next year!

This year I would like to take the opportunities to keep expanding Community Closeups and interviewing all the amazing people in our community (hopefully more interviews than last year!).  If you want to be on the show… reach out!!

Per usual, you’ll be able to find me in the forums. Whether it’s Reddit, SP-Dev-Docs or Facebook groups, if you need something, you know where to find me! 🙂

Thanks again!

SharePoint JSON Formatting – “Name cannot begin with the ‘=’ character”

Have you ever tried to apply column formatting to your SharePoint list views using code? If so, it’s likely you have come across this error and unfortunately there isn’t much information in the documentation on how the JSON Formatter string should be formatted.  Take for example this scenario:

Updating a view format using PnP PowerShell

PnP PowerShell has functionality for setting properties of a view using the command Set-PnPView

Updating the formatting on your list is done by passing in a JSON string to your Set-PnPView command using the CustomFormatter value property.

Set-PnPView -List "MyList" -Identity "MyViewName" -Values @{CustomFormatter = @'my-json-formatted-string'@}

Example JSON

Below is a basic example of applying a background color to a row on a view when the DueDate is less than now (date time).

{"schema":"https://developer.microsoft.com/json-schemas/sp/view-formatting.schema.json","additionalRowClass": "=if([$DueDate] <= @now, 'sp-field-severity--severeWarning', '')"}

Applying this format using PnP PowerShell

To apply this JSON script, you would use the following command

Set-PnPView -List "MyList" -Identity "MyViewName" -Values @{CustomFormatter = @'
{"schema":"https://developer.microsoft.com/json-schemas/sp/view-formatting.schema.json","additionalRowClass": "=if([$DueDate] <= @now, 'sp-field-severity--severeWarning', '')"}
'@
}

Running this command, you will likely receive the following error: Set-PnPView : Name cannot begin with the ‘=’ character, hexadecimal value 0x3D.

The Fix:

The reason you are seeing this error is because in the JSON itself you need to encode some of the values if you are using operators. What I mean by that is, if you are using &&, or operators in formulas such as “>=” or “<=“, you need to use their encoded values instead. In our example, we were using [$DueDate] <= @now. In order to apply this to our view, we need to encode “<=” into  “&lt;=” and the formula will work.

Below is the following command with a working JSON formatter value.

Set-PnPView -List "MyList" -Identity "MyViewName" -Values @{CustomFormatter = @'
{"schema":"https://developer.microsoft.com/json-schemas/sp/view-formatting.schema.json","additionalRowClass": "=if([$DueDate] &lt;= @now, 'sp-field-severity--severeWarning', '')"}
'@
}

 

Hope this helps you!

Rendering multi-value Choice fields vertically using JSON Column Formatting

Recently I was perusing a SharePoint forum post and a member asked if there was a way to change the visual representation of a multiple value choice field in SharePoint. My first thought was to use JSON Column Formatting.

The problem

By default, SharePoint renders a multiple value choice field as a single string in a row, and renders the HTML as a single value in a div.

HTMLChoiceField

One question, if you aren’t familiar with JSON Column Formatting is how would we render these items as new lines if they represented as a single value in the HTML. You’d probably first go and see if you could split on the commas “,”… but unfortunately column formatting does not support a split function.

Introducing ‘forEach’

One feature that column formatting does have is the forEach function. This is an optional property that allows an element to duplicate itself for each member of a multi-valued field. To loop through multi-value fields we’d use the following format

"iteratorName in @currentField" or "iteratorName in [$FieldName]"

Once you’ve implemented the forEach, you have access to each member you are looping through by using the iterator name. For example, if we loop through @currentField using the following formula: "iteratorName in @currentField" we can gain access to each record using [$iteratorName].

Putting it into action

Now that we know we can loop through multi-choice fields, all we need to do is come up with a JSON column formatter which creates each record on it’s own row. See the below JSON object.

We are using the forEach property to loop through each choice value in the currentField. For each record, we set the textContext equal to the choice record, and then we just style the div to be displayed block and 100%

{
  "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json",
  "debugMode": true,
  "elmType": "div",
  "children": [
    {
      "elmType": "div",
      "style": {
        "display": "block",
        "width": "100%"
      },
      "forEach": "choice in @currentField",
      "txtContent": "[$choice]"
    }
  ]
}

The end result turns the original choice field, to be rendered like so:

ChoicesHTML

MultipleLineChoice

 

Finding all Delve Blogs in your tenant using Search

Recently, Microsoft has announced they are retiring Delve blogs. In doing so, Microsoft has also given us a schedule of important dates relating to the retirement.

  • Beginning December 18th, 2019, tenants will not have the ability to create new Delve Blogs
  • Beginning January 18th, 2020 the ability to create new posts in existing Delve blogs will be discontinued
  • Beginning April 17th, 2020, existing Delve blogs will be deleted and removed from Delve profiles

If your organization has been using Delve blogs, you are probably thinking “wow, I don’t have much time to migrate Delve blogs into communication sites“. That’s correct, it does feel pretty rushed. If you are looking into finding all of the blog sites in your tenant, here is a search query to help you out.

Search Query

* path:yourtenant/portals/personal* ContentType:"Story Page"'&selectproperties='Author,SPWebUrl'

Search via REST

https://yourtenant/_api/search/query?queryText=’* path:yourtenant/portals/personal* ContentType:”Story Page”‘&selectproperties=’Author,SPWebUrl’

Search Explained

The above search query is fairly simple. It will search everything (*) where the path starts with the Delve Blog locations (path:yourtenant/portals/personal*) where the Content Type is the content type used for Delve Blogs (Story Page).


Another method for finding all blogs being used in your tenant is by using the Modernization Scanner. This is a tool that was designed to help companies modernize their classic sites by scanning tenants looking for things like InfoPath usage, Classic Workflows and more.

Well, starting in version 2.7, it will include the ability to scan your tenant for Delve blogs, using the same search methods above.

 

 

Renewed as a Microsoft MVP for 2019-2020

It’s that time of the year… July 1st has come and gone and the Microsoft MVP renewals have been completed! I am excited and honored to announce that I have been re-awarded Microsoft MVP for Office Apps and Services!

MVP

I’d like to thank Microsoft for continuing my involvement with the MVP program, and embracing the relationship between the platforms we use and the community that is heavily involved with it.

A brief history

10 years ago when I first started SharePoint, I found it to be a difficult platform and technology to work with. As a newbie, I relied heavily on the SharePoint community support forums to help me find solutions to problems that I was seeing in my role as a SharePoint developer. The SharePoint community was more supportive than I could have ever imagined. As a result, I’ve always felt that I would like to give back to the community in the same way that benefited me prior.

In 2017, I was surprised and honored to be awarded MVP for my contributions in the support forums and my technical blogging. If you haven’t seen me around, you can usually find me trying to provide SharePoint support on Collab365, SharePoint Reddit, Facebook Groups, and Sp-Dev-Docs GitHub.

Looking forward

This year I am hoping to expand some of my contributions in areas I’ve shied away from in the past. One of the biggest initiatives is the start of a new video series called Community Closeups with my good friend and fellow MVP, David Warner. Community Closeups is a video series that highlights the unique and talented individuals in our community. It’s a laid back interview where we get personal and talk about what makes us unique — and of course, lots of SharePoint!

Along side this series, I am going to continue some of my technical blogging on this site and hope to contribute further by speaking at more events like SharePoint Saturday. A listing of my upcoming speaking engagements can be found here.

Don’t worry… you’ll still be able to find me around on the forums! 🙂

 

Exploring Modern page templates in SharePoint Online with REST

A long awaited feature in modern SharePoint has finally hit targeted release! We saw the ability to create page templates during Ignite last year and I’m happy to say they have been released to SharePoint Online. This post is going to give an overview of page templates. Be aware that functionality may change as this feature is currently in targeted release.

Who can create page templates?

Page templates can be created by a site owner or a SharePoint administrator.

How to create a page template

Creating a page template is quite easy.  First, create a new site page in your modern site and configure the web parts and sections for your page. Before saving your page, you’ll see a new option  in the “Save as draft” menu called “Save as template”

Template.png

Where are templates stored?

When you create a new template,  the template is stored inside the Site Pages library in a folder called “Templates”

Tempalte2

What properties determine if it’s a template?

If you look  closely at the properties of any Site Page using an API, you’ll see an internal column that denotes specific flags on the current item called OData__SPSitePageFlags. This is a (Collection.EdmString) property and a template will include the value “Template”.

OData__SPSitePageFlags = “Template”

Promote a Site Page as a template via REST

As a developer,  I’m always interested in seeing how we can achieve native UX functionality using code.  There is a REST endpoint available to take an existing page and save it as a template.

The REST endpoint

https://yourtenant.sharepoint.com/_api/sitepages/pages(<id>)/SavePageAsTemplate

Parameters

Body: “{\”__metadata\”:{\”type\”:\”SP.Publishing.SitePage\”}}”

The REST endpoint allows a developer to pass in the ID of the Site Page list item and POST to /SavePageAsTemplate to promote the page as a template. This will create a copy of the Site page and place it inside the /Templates folder.

REST Call Example

fetch("https://yourtenant.sharepoint.com/_api/sitepages/pages(6)/SavePageAsTemplate", {
    "credentials": "include",
    "headers": {
        "accept": "application/json",
        "accept-language": "en-US,en;q=0.9",
        "cache-control": "max-age=0",
        "content-type": "application/json;odata=verbose;charset=utf-8",
        "if-match": "*",
        "odata-version": "3.0",
        "x-http-method": "POST",
        "x-requestdigest": "0x0BA2BEBD58CB0E0673101B350B558A96D98A236A3170597FA62AA5B4D47A52E210E801048FA58AAA6218204AB7E861D770A0924753A1E071DC6812240C359615,13 May 2019 15:00:48 -0000"
    },
    "body": "{\"__metadata\":{\"type\":\"SP.Publishing.SitePage\"}}",
    "method": "POST",
    "mode": "cors"
});

View all templates via REST

Now that we know how to create a page template from an existing page, let’s show how to find all available templates via REST.

The REST endpoint

https://yourtenant.sharepoint.com/_api/sitepages/pages/templates?asjson=1

Parameters

asjson: 1

The REST endpoint accepts a GET request to return all templates in your site from the Site Pages library

REST Call Example

fetch("https://yourtenant.sharepoint.com/_api/sitepages/pages/templates?asjson=1", {
    "credentials": "include",
    "headers": {
        "accept": "application/json;odata.metadata=minimal",
        "accept-language": "en-US,en;q=0.9",
        "if-modified-since": "Mon, 13 May 2019 15:06:59 GMT",
        "odata-version": "4.0"
    },
    "body": null,
    "method": "GET",
    "mode": "cors"
});

 

REST Call Response

The REST call returns an array of SP.Publishing.SitePageMetadata objects

{
  "@odata.context":"https://yourtenant.sharepoint.com/_api/$metadata#SitePageMetadatas",
  "value":[{
    "@odata.type":"#SP.Publishing.SitePageMetadata",
    "@odata.id": "https://yourtenant.sharepoint.com/_api/SP.Publishing.SitePageMetadatac155ab55-017f-4594-9880-e73ff6b0f06e",   
    "@odata.editLink": "SP.Publishing.SitePageMetadatac155ab55-017f-4594-9880-e73ff6b0f06e",
    "AbsoluteUrl": "https://yourtenant.sharepoint.com/SitePages/Templates/Test(1).aspx",
    "AuthorByline": ["i:0#.f|membership|beau@yourtenant.onmicrosoft.com"],
    "BannerImageUrl": "https://yourtenant.sharepoint.com/_layouts/15/images/sitepagethumbnail.png",
     "BannerThumbnailUrl": "",
        "ContentTypeId": null,
        "Description": "How do you get started? Tests Select 'Edit' to start working with this basic two-column template with an emphasis on text and examples of text formatting. With your page in edit mode, select this paragraph and replace it with your own text. Then, se\u2026",
        "DoesUserHaveEditPermission": true,
        "FileName": "Test(1).aspx",
        "FirstPublished": "0001-01-01T00:00:00-08:00",
        "Id": 8,
        "IsPageCheckedOutToCurrentUser": true,
        "IsWebWelcomePage": false,
        "Modified": "2019-05-13T15:11:52Z",
        "PageLayoutType": "Article",
        "Path": {
            "DecodedUrl": "SitePages/Templates/Test(1).aspx"
        },
        "PromotedState": 0,
        "Title": "Test",
        "TopicHeader": "TEXT ABOVE TITLE",
        "UniqueId": "0ef8700a-07de-4a44-8793-3a38a8e3189d",
        "Url": "SitePages/Templates/Test(1).aspx",
        "Version": "1.0",
        "VersionInfo": {
            "LastVersionCreated": "0001-01-01T00:00:00-08:00",
            "LastVersionCreatedBy": ""
        }
}]
}

 

Let me know if you have any interesting ideas for how to incorporate these page templates into your custom solutions!

Experiment – Find out where SPFx Web Parts are being used in Modern SharePoint sites

Recently, I saw a post on Tech Community that asked if there were APIs available to find out where a specific web part may be used in an environment. The reason was to provide a list of sites so an email could be sent to those specific site owners to let them know that a deployment was going to happen for some SPFx web parts.

My first thought was to loop through all of the sites and find out if the the SharePoint Framework app had been installed. This would work except in the case of a tenant wide deployment of the SharePoint Framework web parts. So instead of finding out where a web part has been installed, we need to find out where a web part was actually being used.

The Experiment

To me, this sounded like a great idea, unfortunately, I wasn’t aware of any APIs that were able to do this. Then I got to thinking, maybe we could use the search API to do this. In those post, I am going to try and see if we can use the Search API to find web part usage in SharePoint. Be aware this solution would only work for modern pages using your SPFx web part.

CanvasContent1

When adding web parts to a modern site page in Office 365, the HTML content is saved into a column called “CanvasContent1”. I quickly looked at the search schema to see if I would be able to search on this column.

canvascontent

Unfortunately, by default, the CanvasContent1 managed property called CanvasContent1OWSHTML didn’t have a crawled property mapped to it.  So I decided to map ows_CanvasContent1 to a new RefinableString.

untitled

Now, in order for the column to be searchable we have to wait for the column to re-crawl in our environment… this could take some time in SharePoint Online.

What to search for

The CanvasContent1 field contains a bunch of html data about the contents of the page. This includes web parts and their configuration including properties. Stored inside the CanvasContent1 field will also include the ID of the web parts configured on the page.With this in mind, I figured it would be fairly easy to find where a web part is being used in an environment by searching against this field.

Let’s say that I have a web part with the component id of 62799350-83b2-40a1-b35d-5417cc54daea as shown in this SPFx manifest file.

untitled

If I execute a search query against the RefinableString field where it contains this GUID, I should be fairly certain the page contains my web part.

Using the SharePoint Search  REST API, I can execute the following call to return the Title, Path and Site of the page that is rendering my web part.

Request

https://testsite.sharepoint.com/sites/test/_api/search/query?QueryText='RefinableString134:62799350-83b2-40a1-b35d-5417cc54daea*'&selectProperties='Title,Path,SPWebUrl'

Response

In the response, I have found 2 pages where my web part is being loaded.

<d:query xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" xmlns:georss="http://www.georss.org/georss" xmlns:gml="http://www.opengis.net/gml"m:type="Microsoft.Office.Server.Search.REST.SearchResult">
<d:ElapsedTime m:type="Edm.Int32">104</d:ElapsedTime>
<d:PrimaryQueryResult m:type="Microsoft.Office.Server.Search.REST.QueryResult">
<d:CustomResults m:type="Collection(Microsoft.Office.Server.Search.REST.CustomResult)"/>
<d:QueryId>37de1142-2baf-4ea6-ab0b-b646a0a57d31</d:QueryId>
<d:QueryRuleId m:type="Edm.Guid">00000000-0000-0000-0000-000000000000</d:QueryRuleId>
<d:RefinementResults m:null="true"/>
<d:RelevantResults m:type="Microsoft.Office.Server.Search.REST.RelevantResults">
<d:GroupTemplateId m:null="true"/>
<d:ItemTemplateId m:null="true"/>
<d:Properties m:type="Collection(SP.KeyValue)">
<d:element>
<d:Key>GenerationId</d:Key>
<d:Value>9223372036854775806</d:Value>
<d:ValueType>Edm.Int64</d:ValueType>
</d:element>
<d:element>
<d:Key>indexSystem</d:Key>
<d:Value/>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>ExecutionTimeMs</d:Key>
<d:Value>47</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element>
<d:Key>QueryModification</d:Key>
<d:Value>
RefinableString134:62799350-83b2-40a1-b35d-5417cc54daea* -ContentClass=urn:content-class:SPSPeople
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>RenderTemplateId</d:Key>
<d:Value>
~sitecollection/_catalogs/masterpage/Display Templates/Search/Group_Default.js
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>StartRecord</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element>
<d:Key>IsLastBlockInSubstrate</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsFirstBlockInSubstrate</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsFirstPinnedResultBlock</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsLastPinnedResultBlock</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsFirstRankedResultBlock</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsLastRankedResultBlock</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>MixedTableOrder</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
</d:Properties>
<d:ResultTitle m:null="true"/>
<d:ResultTitleUrl m:null="true"/>
<d:RowCount m:type="Edm.Int32">2</d:RowCount>
<d:Table m:type="SP.SimpleDataTable">
<d:Rows>
<d:element m:type="SP.SimpleDataRow">
<d:Cells>
<d:element m:type="SP.KeyValue">
<d:Key>Rank</d:Key>
<d:Value>16.8176937103271</d:Value>
<d:ValueType>Edm.Double</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>DocId</d:Key>
<d:Value>17601926184608</d:Value>
<d:ValueType>Edm.Int64</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Title</d:Key>
<d:Value>Home</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Path</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlertnativeHome.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>SPWebUrl</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>OriginalPath</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlertnativeHome.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>PartitionId</d:Key>
<d:Value>ca45b536-df01-44b6-afa0-d3f8e7ebb312</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>UrlZone</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Culture</d:Key>
<d:Value>en-US</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>ResultTypeId</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>RenderTemplateId</d:Key>
<d:Value>
~sitecollection/_catalogs/masterpage/Display Templates/Search/Item_Default.js
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
</d:Cells>
</d:element>
<d:element m:type="SP.SimpleDataRow">
<d:Cells>
<d:element m:type="SP.KeyValue">
<d:Key>Rank</d:Key>
<d:Value>16.8176937103271</d:Value>
<d:ValueType>Edm.Double</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>DocId</d:Key>
<d:Value>17601957874490</d:Value>
<d:ValueType>Edm.Int64</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Title</d:Key>
<d:Value>Home</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Path</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlternativeHome2.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>SPWebUrl</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>OriginalPath</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlternativeHome2.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>PartitionId</d:Key>
<d:Value>ca45b536-df01-44b6-afa0-d3f8e7ebb312</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>UrlZone</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Culture</d:Key>
<d:Value>en-US</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>ResultTypeId</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>RenderTemplateId</d:Key>
<d:Value>
~sitecollection/_catalogs/masterpage/Display Templates/Search/Item_Default.js
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
</d:Cells>
</d:element>
</d:Rows>
</d:Table>
<d:TotalRows m:type="Edm.Int32">2</d:TotalRows>
<d:TotalRowsIncludingDuplicates m:type="Edm.Int32">2</d:TotalRowsIncludingDuplicates>
</d:RelevantResults>
<d:SpecialTermResults m:null="true"/>
</d:PrimaryQueryResult>
<d:Properties m:type="Collection(SP.KeyValue)">
<d:element>
<d:Key>RowLimit</d:Key>
<d:Value>500</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element>
<d:Key>SourceId</d:Key>
<d:Value>8413cd39-2156-4e00-b54d-11efd9abdb89</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element>
<d:Key>CorrelationId</d:Key>
<d:Value>02b6b69e-10c1-7000-727b-bc8a6f5546b9</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element>
<d:Key>WasGroupRestricted</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsPartial</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>HasParseException</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>WordBreakerLanguage</d:Key>
<d:Value>en</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>IsPartialUpnDocIdMapping</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>EnableInterleaving</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsMissingUnifiedGroups</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>Constellation</d:Key>
<d:Value>iC6B8D</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>SerializedQuery</d:Key>
<d:Value>
<Query Culture="en-US" EnableStemming="True" EnablePhonetic="False" EnableNicknames="False" IgnoreAllNoiseQuery="True" SummaryLength="180" MaxSnippetLength="180" DesiredSnippetLength="90" KeywordInclusion="0" QueryText="RefinableString134:62799350-83b2-40a1-b35d-5417cc54daea*" QueryTemplate="" TrimDuplicates="True" Site="532bd9a4-869f-45e2-b5f0-323f6604a429" Web="c67564a0-242f-40bf-b4ac-a0ea8dbc935e" KeywordType="True" HiddenConstraints="" />
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
</d:Properties>
<d:SecondaryQueryResults m:type="Collection(Microsoft.Office.Server.Search.REST.QueryResult)"/>
<d:SpellingSuggestion/>
<d:TriggeredRules m:type="Collection(Edm.Guid)"/>
</d:query>

In my result set, I have found two pages in the same site that was using this specific web part. With a larger result set that comes back, I could loop through the records and figure out which sites are running my web part, and send an email accordingly to each site owner about the upcoming web part deployment!

Final Thoughts

This was a fun experiment to test out and I am going to continue to do some more exploration to see if there are other methods possible for finding out this information. I haven’t been able to do thorough testing, but I would love to hear if this works for you and/or if you have another approach!

Caveats/Feedback

  1. This would only work for web parts placed on modern pages
  2. If site owners have disabled content from being searchable, it will not show up. (Thanks Paul Bullock!)
  3. There are much better approaches to monitoring this type of information. As Sam Crewdson noted on twitter  the use of Application Insights is a robust way to track and monitor solutions in your environment. Here is a blog post by Chris O’Brien showing how to use application insights with SPFx.