Create a modern SharePoint site template with multiple pages using PnP provisioning engine
The PnP provisioning engine is a framework that allows you to create templates based on pre-built SharePoint sites. The framework was not built to be a migration tool and when a template is generated it only saves the home page, although the schema supports multiple pages.
In this article you can find a PowerShell script that saves a modern SharePoint site as a template with all the included pages.
The algorithm to save all the pages is quite simple and all the commands used are available in the PnP PowerShell.
It starts by saving the site as a template with all properties and definitions included, then it loops through all the pages in the Site Pages library, set each page as home page of the site and saves a new template with the name of the page.
Since all the site properties and definitions were saved before each subsequent save only gets the page using the -Handlers PageContents
option, when the last page is saved the original home page is applied to the site.
The script ends by copying the first template saved and by importing all the page nodes from the smaller templates, generating then a full template with all the pages.
The full script is available below.
pushd (Split-Path -Path $MyInvocation.MyCommand.Definition -Parent) $saveDir = (Resolve-path ".\") $siteURL = Read-Host "Please enter your site URL" Write-Host "Connecting to: $siteURL" Connect-PnPOnline -Url $siteURL; Write-Host "Connected!" $web = Get-PnPWeb $sourceSite = $web.ServerRelativeUrl $library = "Site Pages" #get all pages in the site pages library $pages = Get-PnPListItem -List $library #save current homepage $currentHomePage = Get-PnPHomePage $pagesList = New-Object System.Collections.Generic.List[System.Object] $pageNumber = 1 foreach($page in $pages){ if($page.FileSystemObjectType -eq "File"){ $pagePath = $page.FieldValues["FileRef"] $pageFile = $pagePath -replace $sourceSite, "" $pageTemplate = $pageFile -replace "/SitePages/","" -replace ".aspx",".xml" $pagesList.Add($($pageTemplate -replace "./TemplateWithPages","")) #set current page as home page Set-PnPHomePage -RootFolderRelativeUrl ($pagePath -replace ($sourceSite+"/"), "") Write-Host ("Saving page #" + $pageNumber + " - " + $pageTemplate) if($pageNumber -eq 1){ Get-PnPProvisioningTemplate -Out $($saveDir.Path + "\" + $pageTemplate) }else{ Get-PnPProvisioningTemplate -Out $($saveDir.Path + "\" + $pageTemplate) -Handlers PageContents } $pageNumber++ } } $pagesList.ToArray() #apply default default homepage Set-PnPHomePage -RootFolderRelativeUrl $currentHomePage #copy base template Copy-item -path ($saveDir.Path + "\" + $pagesList[0]) -destination ($saveDir.Path + "\Template.xml") #open main xml $mainFile = [xml][io.File]::ReadAllText($($saveDir.Path + $("\Template.xml"))) $clientSidePages = $mainFile.Provisioning.Templates.ProvisioningTemplate.ClientSidePages #remove pages to avoid duplicates $clientSidePages.RemoveChild($mainFile.Provisioning.Templates.ProvisioningTemplate.ClientSidePages.ClientSidePage) foreach( $page in $pagesList ){ #open page template xml $xmlContents = [xml][io.File]::ReadAllText($saveDir.Path + "\" + $page) foreach($node in $xmlContents.Provisioning.Templates.ProvisioningTemplate.ClientSidePages.ClientSidePage) { #copy nodes from page xml $importNode = $clientSidePages.OwnerDocument.ImportNode($node, $true); $clientSidePages.AppendChild($importNode) | Out-Null #save final tempate $mainFile.Save($($saveDir.Path + $("\Template.xml"))) } } popd
February 22, 2018
Thanks for this João, very useful
For my clarity, as I’m not a PS expert, what are the variables in here that we need to modify / configure when readying the script for our own sites?
February 23, 2018
Hi Simon, you just need to save the script in a ps1 file and execute it. The script will request the url to your own modern site
April 5, 2018
Hello there.
Thank you for this nice example, it looks very promising.
I am a beginner with this and run into one problem though so far: In line 55, the $clientSidePages are null, so all the consecutive calls to this variable fail.
Would you know why this variable is empty, how to avoid it or how to add this node into the XML? Since it seems that i really need it right?
Thank you very much.
April 5, 2018
One more Information: Also the ClientSidePages of each $xmlContents in line 63 are null for me. Do you have any suggestion? Thank you in advance.
April 5, 2018
Hi Thomas,
Please make sure you are using this script with modern SharePoint Team Sites or Communication Sites.
This node is generated by the PnP Provisioning and it generates a node for each site page in the site.
Can you add a break point to line 14 and let me know what is the result of the $pages variable?
April 5, 2018
Thank you for your reply.
I am not realy sure about what is a “modern” TeamSite, but I am confident that I am using this.
The content of the $pages variable is: Microsoft.SharePoint.Client.ListItem Microsoft.SharePoint.Client.ListItem Microsoft.SharePoint.Client.ListItem
So there are three pages inside there. Namely they are “Home.aspx”, “How to use this library.aspx” and “Core-Data.aspx”. The first two seem to be there by default, the last one is the only one I am interested in actually.
I double-checked the exported XML files for these three pages, and actually the last one contains the node “ClientSidePages”. This is good.
The other two pages do not have this node though.
And also the $mainFile does not have this node..
I could try to just add a new node there with this name but I am unable to create a node called , instead I am only able to create a node called (without the pnp prefix). I tried it using this Approach:
$child = $mainFile.CreateElement(“pnp:ClientSidePages”)
$mainFile.Provisioning.Templates.ProvisioningTemplate.AppendChild($child)
I hope you can follow my thoughts here. Thank you very much for any Input.
April 17, 2018
Thomas,
According to your description and after debugging it seems that your site is not a modern team site.
Let me know if it looks like the site in the image below:
April 18, 2018
Hello Joao.
Thanks for your reply. My site looks like this. At least the navigation on the left and the top and header of the page. The actual contents in the middle of the page are not the same, but i guess that does not matter?
Anyway, I was now able to make your example work for me with a little extra xml modification in my powershell script. 😉 Thank you for your kind help!
By the way, since you seem to be an expert with PnP and Sharepoint Online:
Do you know if a similar way is possible for adding subsites (not pages) to the xml template?
Thank you!
April 18, 2018
Hi Thomas,
I’m glad it worked for you.
The creation of subsites is not part of the PnP Schema, a possible workarround is to create the templates for each site and then create a PowerShell script to execute the templates creation in the desired order.
You just need to change the site context after each site creation to deploy a template as a subsite of the new provisioned site.
April 5, 2018
In my previous reply it seems that the names of the node I am trying to create got missing.
I am trying to create a node called pnp:ClientSidePages but i always end up with a node just called ClientSidePages (without the pnp prefix).
April 17, 2018
Hi Thomas,
The node can not be created manually, if you end up with a node called ClientSidePages you are not using a modern site, instead you are using a classic Publishing site or classic team site with the publishing features enabled.
May 24, 2018
after I apply, how can I undo? if I want back to default setting.
May 28, 2018
Hi Kelvin,
After applying the template is not possible to revert and remove all the assets applied with it.
May 31, 2018
this can support sharepoint 2016 on premise? or just can apply on sharepoint online?
May 31, 2018
I already run the script and after run i get the sitepages.xml on my desktop?
How I can insert the xml file to my sharepoint 2016 on premise?
May 31, 2018
Hi Kelvin,
In this article you have the powershell script to apply the XML to the site
http://sharepoint.handsontek.net/2017/10/29/save-publishing-site-as-template-the-right-way-using-pnp-provisioning/
Let me know if you need any further clarification.
May 31, 2018
Hi Kelvin,
This script was made specifically for SharePoint online but since it uses PnP you will be able to adapt it to SharePoint on Prem 2016.
If you are using a Publishing site change the library name from Site Pages to Pages and you should get all the pages of the site in your template.
August 13, 2018
Any idea why i would be getting this?
You cannot call a method on a null-valued expression.
At C:\Users\zwelding\OneDrive – BlueNet Inc\Searchwide\template.ps1:58 char:1
+ $clientSidePages.RemoveChild($mainFile.Provisioning.Templates.Provisi …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At C:\Users\zwelding\OneDrive – BlueNet Inc\Searchwide\template.ps1:67 char:3
+ $importNode = $clientSidePages.OwnerDocument.ImportNode($node …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At C:\Users\zwelding\OneDrive – BlueNet Inc\Searchwide\template.ps1:68 char:3
+ $clientSidePages.AppendChild($importNode) | Out-Null
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
August 13, 2018
i ran it again on the root site and it worked. Our structure is /sites/clientportal (this is the main modern site) we then have subsites under that for each client(sites/clientportal/client1). That initial subsite is what i would like to save as a template so that for each client we can create a new site. Will this work for that?
August 13, 2018
Final question, how do you actually apply the template once its been saved?
August 13, 2018
Zach,
Once saved you can apply the template following the instructions on the Set Template section of this article
August 13, 2018
Hi Zach,
I’m assuming the sub sites are classic sites, if you are using classic make sure you are using Collaboration Sites otherwise you will not have the library Site Pages.
If you are using Publishing you need to modify the script to reference Pages Library instead of Site Pages.
August 16, 2018
I have a site pages library, no publishing enabled. So i dont think i need to modify the script?
I created a communication site first, then created classic subsites underneath that with modern pages.
August 26, 2018
Hi Zach,
This script was made to save the root modern site and the pages that belong to it. To save classic sub sites with pages it might need to be adjusted.
September 19, 2018
You are awesome. You script works like a charm.
September 19, 2018
I am able to export the pages as well as template xml file. When i use the following pnp command i get “File Not Found”. I want only pages to be created in the new site. Is it possible ?
$url = “https://XXXXXXX.sharepoint.com/sites/NewSiteUsingTemplate1”
$cred = Get-Credential
Connect-PnPOnline $url -Credential $cred
Write-Host “Adding Template…” -ForegroundColor Yellow
Apply-PnPProvisioningTemplate -Path ‘.\Template.xml’
Write-Host “Template Added” -ForegroundColor Green
November 25, 2018
Hi Pappu,
Please make sure the path to the file is correct.
Apply-PnPProvisioningTemplate -Path ‘.\Template.xml’
Tipically this happens when the command does not find the xml file.
Thanks,
João
October 2, 2018
[…] is possible to get all of the pages for a site using a bit of cunning. João Ferreira has written a script which cycles through all of the pages in a site, sets them as the home page and then exports the […]
October 4, 2018
Hello I used this script thank you by the way. But I used it to help a company out with creating a template for a final project group and I get and error on line 54:
Exception calling “ReadAllText” with “1” argument(s): “Access to the path ‘C:\junk\Template.xml’ is denied.”
At C:\junk\DaytonRogers_PSClone.ps1:54 char:65
+ … = [xml][io.File]::ReadAllText($($saveDir.Path + $(“\Template.xml”)))
+ ~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : UnauthorizedAccessException
You cannot call a method on a null-valued expression.
At C:\junk\DaytonRogers_PSClone.ps1:58 char:5
+ $clientSidePages.RemoveChild($mainFile.Provisioning.Templates.Pro …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
Is there any help I can get please.
October 4, 2018
Hi Chris,
The path to the file doesn’t seem correct. Not sure if you are trying to save it on the root of your disk, if you are try to save the template on your desktop. Also check if you user folder has any special character if it has powershell might fail saving the file.
October 18, 2018
João Ferreira,
First of all want to say thanks for you blog posts. Really useful to folks getting off the ground with stuff like this.
I have a modern collaboration site which I have created and added a few test pages to. The URL to the site is
https:///sites/provisioning101/
where is my dev tenant URL. When I run your script it starts off OK but then after it finishes the last single page it starts spitting out the following
———————–
Connected!
Saving page #1 – Home.xml
Saving page #2 – Sam-was-here.xml
Saving page #3 – Page-2.xml
Saving page #4 – Page-3.xml
Home.xml
Sam-was-here.xml
Page-2.xml
Page-3.xml
You cannot call a method on a null-valued expression.
At D:\export-all-pages-from-SPO-site.ps1:59 char:1
+ $clientSidePages.RemoveChild($mainFile.Provisioning.Templates.Provisi
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At D:\export-all-pages-from-SPO-site.ps1:68 char:3
+ $importNode = $clientSidePages.OwnerDocument.ImportNode($node
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At D:\export-all-pages-from-SPO-site.ps1:69 char:3
+ $clientSidePages.AppendChild($importNode) | Out-Null
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At D:\export-all-pages-from-SPO-site.ps1:68 char:3
+ $importNode = $clientSidePages.OwnerDocument.ImportNode($node
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At D:\export-all-pages-from-SPO-site.ps1:69 char:3
+ $clientSidePages.AppendChild($importNode) | Out-Null
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At D:\export-all-pages-from-SPO-site.ps1:68 char:3
+ $importNode = $clientSidePages.OwnerDocument.ImportNode($node
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
You cannot call a method on a null-valued expression.
At D:\export-all-pages-from-SPO-site.ps1:69 char:3
+ $clientSidePages.AppendChild($importNode) | Out-Null
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (:) [], RuntimeException
+ FullyQualifiedErrorId : InvokeMethodOnNull
———
Can you please give me any pointers?
What I want to do is be able to point this script at any modern site I choose on my DEV tenant, and then use that package to populate with pages etc a site on my PROD Tenant.
As an aside – are you aware of any other ways of doing this now? Perhaps one of the MS SPO engineers has created something too?
Kindest regards
Sam
October 18, 2018
Hello,
I worked out what was wrong. I’ve been playing around so much I had created my test site and I had added pages, but what I had NOT done was edit and then publish the home page. It turns out I didn’t have to make any edits – didn’t need to add any text etc – but simply opening in edit mode, and then publishing is enough to make your script work fine!
Always expect the unexpected!
Does that compute with regards why it would be failing?
Kind regards
Sam
October 18, 2018
Me again!
I’m loving this script of yours. I’ve now had a fiddle with the XML and have added new pages programmatically by editing it. I have also seen that I can add News items.
How though can I export Events that I have added to a modern comms site?
Any pointers?
Kind regards
October 18, 2018
Hi Sam,
I’m happy you were able to get it working.
About your latest request the last version of PnP PowerShell has an option to get data from the lists into the templates once you get them Saved.
Since the events are stored in a SharePoint calendar you can use the command explained in this link.
https://docs.microsoft.com/en-us/powershell/module/sharepoint-pnp/add-pnpdatarowstoprovisioningtemplate?view=sharepoint-ps
Kind Regards
November 5, 2018
Hello João, greetings from Portugal.
First of all, I want to thank you for sharing your knowledge in the Sharepoint world with the entire Web community.
I have some knowledge of Sharepoint 2010 and have now started working with Sharepoint Online. I needed to create a site template, and your solution seemed remarkably useful to me. I tried it and it worked very well. However there is a problem that I still can not figure out how to solve and for this reason I am exposing the same to see if anyone can help me.
The situation:
1- My original site has a library of site pages and I created in this library 2 new columns (Choice type), for example: Category, Tags.
2- I created 3 new pages and in the details of the page the fields “Category” and “Tags” have been filled.
3- When accessing the Library Settings, I see that the created columns have the following content types assigned in “Used in”: Site Page, Republishing Page, Wiki Page, Web Page Page.
4- On the Home Page, I added 2 “Highlighted Content” Web Parts to filter pages from the Site Pages library by Property.
5- I created the template.xml using your great script.
6- I created a new site and applied the template.xml.
The Problem:
7- All Pages have been created, but when I access the Site Pages library, I see that the “Category” and “Tags” columns are empty. By accessing the Library Settings, I see that the Category and Tags columns don`t have any content types assigned in “Used in”. Can the problem be related to this?
8- On the Home page, the “Enhanced Content” Web Parts appear unconfigured because the “Categories” and “Tags” columns are empty. Question: Is there a way to apply the template to a new site and the columns created in the Site Page Library are filled out?
November 25, 2018
Hello Fernando,
PnP provisioning engine will not get the content you create with the pages automatically.
It get the definitions but the content is not added, this is a limitation added by design to avoid this tool being used as migration tool.
Although it does not get the content by default you can add it manually to your xml file, in most cases the schema has support for it, you can check the provisioning schema in the page below.
https://github.com/SharePoint/PnP-Provisioning-Schema
My best,
João Ferreira
December 14, 2018
Hi João – many thanks for this, a really useful script. Your blog posts are excellent as well.
December 14, 2018
Hi Nick,
Thanks for your feedback.
January 15, 2019
Hi João,
is there any way to hire you to fix the small issues I am getting with the template?
Regards
Sam
March 26, 2019
Hi Sam,
Can you please let me know what are the issues you are having with the template?
February 6, 2019
Hello,
I am trying to use this script today, and it doesn’t seem to work on any new sites. As soon as it gets to each page, I get an error like so:
“Get-PnPProvisioningTemplate : Object reference not set to an instance of an object.
At c:\path\GetTemplate.ps1:38 char:4
+ Get-PnPProvisioningTemplate -Out $($saveDir.Path + “\” + …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : WriteError: (:) [Get-PnPProvisioningTemplate], NullReferenceException
+ FullyQualifiedErrorId : EXCEPTION,SharePointPnP.PowerShell.Commands.Provisioning.GetProvisioningTemplate”
This seems to be an issue with the get-pnpprovisioningtemplate command when it hits the modern pages. I can replicate the issue with that single command, but I can make it go away by deleting pages.
This script hasn’t worked on two separate sites I’ve made today. However, it works fine when I test it on older sites that were already created. New pages or not. It’s a fun issue.
March 7, 2019
Hi Nick,
I wasn’t able to reproduce this issue locally.
Were you able to deploy the sites?
My best,
João Ferreira