Sitecore XP to SitecoreAI Migration: Restructuring Your Content Tree

Sitecore Architect
  • Twitter
  • LinkedIn

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

  1. 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.
  2. 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.
  3. 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.
  4. Move Items: Moves non-page items into their parent page's Data folder. Every move is logged for a complete audit trail.
  5. 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/WorkflowItem

The 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.