Welcome back. If you haven't read Part 1 yet, I'd suggest starting there. It covers getting your templates into Helix structure, adding SitecoreAI base templates, and bulk-installing packages. All of that sets the stage for what we're doing here.
This post is about the part of the migration that takes the longest and causes the most headaches: the actual content restructuring.
The Problem, Spelled Out
After running the official Sitecore Migration Tool and getting your content packages installed, you'll open the content tree and find a mess. Not because anything went wrong. This is simply what XP content looks like when you're honest about it.
In XP, component datasources lived wherever someone decided to put them at the time. Maybe directly under the page item. Maybe in a folder someone called "Content" or "Components," or just left at the root of the site. There was no enforced convention, so over years of development, things ended up everywhere.
SitecoreAI recommends something specific: non-page items should live inside a Data folder directly under the page they belong to. That Data folder uses a specific template from the Foundation layer (/sitecore/templates/Foundation/Experience Accelerator/Local Datasources/Page Data) that gives it the correct rendering behavior.
The job is to walk the entire content tree, identify everything that isn't a page, and move it into the correct Data folder. For more than 100,000 items, that isn't a Saturday afternoon project. It's a script.
Script 4: The Content Restructuring Script
This is the biggest script in the migration toolkit, and the one I'm most proud of. It handles syncing, restructuring, Data folder creation, and cleanup in one run.
What It Does, Step by Step
- Sync: Moves items from the source path to the destination, updates fields on existing items, and moves new ones. It also handles cases where you've already completed some manual cleanup.
- Restructure: Walks the tree recursively. For each page item, it inspects its children. Any child that isn't a page and isn't already in a Data folder gets flagged for moving.
- Create Data Folders: Creates Data folders using the correct Foundation template when they don't already exist. It checks first so you never end up with duplicates.
- Move Items: Moves non-page items into their parent page's Data folder. Every move is logged for a complete audit trail.
- Cleanup: Runs two optional passes: deleting items that match a name pattern, such as "unpublished," and deleting specific items by ID.
Performance: Why Caching Matters
When iterating over tens of thousands of items, naive Sitecore scripting gets slow. Every Get-Item call hits the database, and inside nested loops, that adds up quickly. The script uses two hash table caches to avoid redundant lookups:
- $templateCache: Each template ID is evaluated against $pageTemplateIDs exactly once, no matter how many items share that template.
- $dataFolderCache: Once a Data folder is found or created under a page, it is cached so the script never looks it up again.
For a large site, these two caches turn what could be an hours-long script into something much more manageable.
The Full Script
⚠ Customization Note: Update $sourcePath, $destinationPath, $pageTemplateIDs, $ExcludeItemsID, and $ItemNamePatternToDelete before running. Replace [YourSite] and [YourSiteRoot] with your actual paths. Add every page template ID for your site. Anything missing from that list will be treated as a datasource and moved.
# ============================================================
# Script 4: Content Restructuring, Move Non-Page Items to Data Folders
# Walks the content tree and organizes datasource items under
# a Data folder on each page, as required by SitecoreAI
# ============================================================
# Configure below
# Update these paths to match your site
$sourcePath = "/sitecore/content/[YourSite]/Home"
$destinationPath = "/sitecore/content/[YourSite]/[YourSiteRoot]"
$DATA_TEMPLATE_PATH = "/sitecore/templates/Foundation/Experience Accelerator/Local Datasources/Page Data"
# Items whose name contains this string will be deleted during cleanup
$ItemNamePatternToDelete = "unpublished"
# Specific item IDs to delete during cleanup, comma-separated
$ExcludeItemsID = "{YOUR-ITEM-ID-TO-EXCLUDE}"
# Add ALL page template IDs for your site here
# Anything NOT in this list is treated as a datasource and moved to a Data folder
$pageTemplateIDs = @(
"{YOUR-PAGE-TEMPLATE-ID-1}",
"{YOUR-PAGE-TEMPLATE-ID-2}"
# Add all page template IDs
)
[System.Collections.ArrayList]$unprocessedItems = @()
$templateCache = @{}
$dataFolderCache = @{}
$script:totalItemsUpdated = 0
$script:totalItemsMoved = 0
$script:totalItemsDeleted = 0
$startTime = Get-Date
$sourceRoot = Get-Item -Path $sourcePath
$destinationRoot = Get-Item -Path $destinationPath
if (-not ($sourceRoot -and $destinationRoot)) {
Write-Host "Source or destination path not found." -ForegroundColor Red
return
}
function Update-ItemFields {
param($SourceItem, $DestinationItem)
$fields = $SourceItem.Fields | Where-Object {
$_.Name -notlike "__*"
}
$needsUpdate = $fields | Where-Object {
$DestinationItem[$_.Name] -ne $SourceItem[$_.Name]
}
if ($needsUpdate) {
$DestinationItem.Editing.BeginEdit()
try {
foreach ($f in $fields) {
$DestinationItem[$f.Name] = $SourceItem[$f.Name]
}
$DestinationItem.Editing.EndEdit()
$script:totalItemsUpdated++
Write-Host "Updated: $($DestinationItem.Paths.FullPath)" -ForegroundColor Cyan
}
catch {
$DestinationItem.Editing.CancelEdit()
$unprocessedItems.Add($DestinationItem.Paths.FullPath) > $null
Write-Host "Failed to update: $($DestinationItem.Paths.FullPath)" -ForegroundColor Red
}
}
}
function Test-PageTemplate {
param(
[Parameter(Mandatory)]
[Sitecore.Data.Items.Item]$Item
)
$id = $Item.TemplateID.ToString()
if (-not $templateCache.ContainsKey($id)) {
$templateCache[$id] = $pageTemplateIDs -contains $id
}
return $templateCache[$id]
}
function Get-DataFolder {
param(
[Parameter(Mandatory)]
[Sitecore.Data.Items.Item]$ParentItem
)
$pid = $ParentItem.ID.ToString()
if (-not $dataFolderCache.ContainsKey($pid)) {
$df = $ParentItem.Children | Where-Object {
$_.Name -eq "Data"
}
if (-not $df) {
try {
$df = New-Item `
-Path $ParentItem.Paths.FullPath `
-Name "Data" `
-ItemType $DATA_TEMPLATE_PATH
Write-Host "Created Data folder: $($df.Paths.FullPath)" -ForegroundColor Green
}
catch {
Write-Host "Failed to create Data folder: $($_.Exception.Message)" -ForegroundColor Red
return $null
}
}
$dataFolderCache[$pid] = $df
}
return $dataFolderCache[$pid]
}
function Sync-Items {
param(
[Parameter(Mandatory)]
[Sitecore.Data.Items.Item]$SourceItem,
[Parameter(Mandatory)]
[Sitecore.Data.Items.Item]$DestinationParent
)
Write-Host "Processing: $($SourceItem.Paths.FullPath)"
$existing = @{}
$DestinationParent.Children | ForEach-Object {
$existing[$_.Name] = $_
}
$dest = $existing[$SourceItem.Name]
if ($dest) {
Update-ItemFields `
-SourceItem $SourceItem `
-DestinationItem $dest
}
else {
try {
$SourceItem.MoveTo($DestinationParent)
$script:totalItemsMoved++
Write-Host "Moved: $($SourceItem.Paths.FullPath)" -ForegroundColor Green
$dest = $DestinationParent.Children |
Where-Object { $_.Name -eq $SourceItem.Name } |
Select-Object -First 1
}
catch {
$unprocessedItems.Add($SourceItem.Paths.FullPath) > $null
Write-Host "Failed to move: $($SourceItem.Paths.FullPath)" -ForegroundColor Red
}
}
foreach ($child in @($SourceItem.Children)) {
if ($dest -is [Sitecore.Data.Items.Item]) {
Sync-Items `
-SourceItem $child `
-DestinationParent $dest
}
else {
$unprocessedItems.Add($child.Paths.FullPath) > $null
}
}
}
function Move-NonPageItems {
param(
[Parameter(Mandatory)]
[Sitecore.Data.Items.Item]$ParentItem
)
$nonPage = @(
$ParentItem.Children | Where-Object {
$_.Name -ne "Data" -and -not (Test-PageTemplate $_)
}
)
if ($nonPage) {
$df = Get-DataFolder -ParentItem $ParentItem
if (-not $df) {
Write-Host "Skipping, no Data folder." -ForegroundColor Yellow
return
}
foreach ($item in $nonPage) {
try {
$item.MoveTo($df)
$script:totalItemsMoved++
Write-Host "Moved to Data: $($item.Name)" -ForegroundColor Cyan
}
catch {
$unprocessedItems.Add($item.Paths.FullPath) > $null
Write-Host "Failed: $($item.Name)" -ForegroundColor Red
}
}
}
}
function Process-SitecoreHierarchy {
param(
[Parameter(Mandatory)]
[Sitecore.Data.Items.Item]$CurrentItem
)
foreach ($child in @(
$CurrentItem.Children | Where-Object {
$_.Name -ne "Data"
}
)) {
if (Test-PageTemplate $child) {
Move-NonPageItems -ParentItem $child
Process-SitecoreHierarchy -CurrentItem $child
}
}
}
function Remove-ExcludedItems {
param(
[Parameter(Mandatory)]
[Sitecore.Data.Items.Item]$RootItem,
[Parameter(Mandatory)]
[string]$ExcludeItems
)
foreach ($name in (
$ExcludeItems.Split(",") | ForEach-Object {
$_.Trim()
}
)) {
$toDelete = Get-ChildItem -Item $RootItem -Recurse |
Where-Object {
$_.Name -like "*$name*"
}
foreach ($item in $toDelete) {
try {
$item.Delete()
$script:totalItemsDeleted++
Write-Host "Deleted: $($item.Paths.FullPath)" -ForegroundColor Yellow
}
catch {
Write-Host "Failed to delete: $($item.Paths.FullPath)" -ForegroundColor Red
$unprocessedItems.Add($item.Paths.FullPath) > $null
}
}
}
}
function Remove-ItemsNodeByID {
param(
[Parameter(Mandatory)]
[string]$ExcludeItemsID
)
foreach ($id in (
$ExcludeItemsID.Split(",") | ForEach-Object {
$_.Trim()
}
)) {
try {
$item = Get-Item -Path master: -ID $id -ErrorAction Stop
if ($null -ne $item) {
$item.Delete()
$script:totalItemsDeleted++
Write-Host "Deleted item: $($item.Paths.FullPath)" -ForegroundColor Yellow
}
}
catch {
Write-Host "Failed for ID $id : $($_.Exception.Message)" -ForegroundColor Red
$unprocessedItems.Add($id) > $null
}
}
}
# Main execution
try {
Write-Host "Starting Sync..." -ForegroundColor Green
Sync-Items `
-SourceItem $sourceRoot `
-DestinationParent $destinationRoot
Write-Host "Starting Restructure..." -ForegroundColor Green
Process-SitecoreHierarchy -CurrentItem $destinationRoot
if ($ExcludeItemsID) {
Remove-ItemsNodeByID -ExcludeItemsID $ExcludeItemsID
}
if ($ItemNamePatternToDelete) {
Remove-ExcludedItems `
-ExcludeItems $ItemNamePatternToDelete `
-RootItem $destinationRoot
}
$elapsed = (Get-Date) - $startTime
Write-Host ""
Write-Host "Execution Summary:" -ForegroundColor Yellow
Write-Host "--------------------------------"
Write-Host "Total Time : $($elapsed.TotalMinutes.ToString('F2')) minutes"
Write-Host "Items Updated: $totalItemsUpdated"
Write-Host "Items Moved : $totalItemsMoved"
Write-Host "Items Deleted: $totalItemsDeleted"
Write-Host "Unprocessed : $($unprocessedItems.Count)"
if ($unprocessedItems.Count -gt 0) {
Write-Host "Failed Items:" -ForegroundColor Red
$unprocessedItems | ForEach-Object {
Write-Host " - $_" -ForegroundColor Red
}
}
}
catch {
Write-Host "Critical error: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Stack trace: $($_.Exception.StackTrace)" -ForegroundColor Red
}
finally {
$templateCache.Clear()
$dataFolderCache.Clear()
}
Reading the Output
At the end of every run, the script outputs an execution summary:
Execution Summary:
--------------------------------
Total Time : 12.43 minutes
Items Updated: 847
Items Moved : 2341
Items Deleted: 156
Unprocessed : 3
Failed Items:
- /sitecore/content/[YourSite]/Home/SomePage/LockedItem
- /sitecore/content/[YourSite]/Home/AnotherPage/WorkflowItemThe failed items list is your manual cleanup queue. These are typically locked items, items in workflow states that prevent moving, or other edge cases. In practice, it is usually a small number, and calling them out explicitly means nothing falls through the cracks.
One More Thing: The Media Library
Everything described above applies to the content tree. But your media library has the same problem. Years of XP projects tend to leave media scattered without a consistent structure.
The good news is that the same pattern works there too. A similar sync-and-restructure script can walk your media library and reorganize items into a Helix-compliant folder structure. I'll cover that in a future post, but if you're mid-migration and can't wait, the Sync-Items and Process-SitecoreHierarchy functions from Script 4 are a solid starting point to adapt.
Closing Thoughts
No script is going to handle every edge case on your specific site. Every project has accumulated its own quirks, and you'll almost certainly need to adjust things. You may need to add template IDs, update paths, or handle a custom item type that doesn't fit the standard pattern.
But that's the point. Scripting gives you something you can inspect, understand, and modify. It's much better than clicking through the UI for two weeks, and it's repeatable. Run it, review the output, adjust it, and run it again.
The patterns here have held up across multiple migrations. Helix template relocation, base template injection, bulk package installation, and Data folder restructuring are consistent pain points, and scripts are consistently the answer.
If you're in the middle of a migration and something isn't adding up, feel free to reach out. Migration work is genuinely difficult, and it helps to talk it through with someone who has been there.
Good luck out there.
Missed Part 1? It covers template migration, base template setup, and bulk package installation.

