Sitecore XP to SitecoreAI Migration: Why Your Best Tool Is a Script

Sitecore Architect
  • Twitter
  • LinkedIn

Let me be upfront about something, migrating from Sitecore XP to SitecoreAI is not fun. I've been through it, and no matter how well you plan, there's a moment mid-migration where you're staring at a content tree with 100,000+ items thinking, "there has to be a better way than doing this by hand."

There is. Scripts.

In this two-part series, I want to share the scripting approach that saved us during a large-scale XP-to-SitecoreAI migration, what we built, why we built it, and how it works. This first part covers the foundation work: getting your templates in order and installing packages without losing your mind. Part 2 tackles the heavy lifting, actually restructuring all that content.

 

First, Why Is This Migration So Painful?

The core problem is that Sitecore XP and SitecoreAI think about content structure in fundamentally different ways.

In XP, there were no rules. Datasource items, the actual content behind your components, could live pretty much anywhere. Under the page, beside the page, tucked into some random folder someone created in 2019. Every site develops its own quirks over the years, and XP never really forced you to clean it up.

SitecoreAI is different. It expects structure. Specifically, it expects:

  • Templates to follow Helix architecture, sitting under /sitecore/templates/Project/, /Feature/, or /Foundation/
  • Non-page content items (Datasource) to live inside a dedicated Data folder under their parent page traditionally.
  • Page templates to properly inherit base templates from the SitecoreAI starter kit, things like Page Design and SEO/Navigation fields

The official Sitecore XP-to-SitecoreAI Migration Tool helps you get content across the wire, but it won't fix your structure. That part is on you. And for a site of any real size, doing it item by item through the UI isn't a plan, it's punishment.

Sitecore Pathways is a newer approach, but it comes with about 70% accuracy and not every aspect is supported.

That's where scripting comes in. Every site is built differently, every client has accumulated their own content debt, and scripts let you build something tailored to exactly what you're dealing with. And they are re-usable.

 

Script 1: Moving Templates into Helix Structure

The first thing to sort out is your template tree. In XP, templates typically lived somewhere under /sitecore/templates/User Defined/. SitecoreAI wants them organized properly under the Helix layers.

This script takes a list of template IDs and moves each one to the correct location under /sitecore/templates/Project/, preserving the folder hierarchy and creating any missing folders along the way. If a template already partially exists at the destination, it merges the fields rather than failing or skipping, which is invaluable when running migrations incrementally.

Customization Note: Update $sourcePath, $destinationPath, and $templateIds before running. Replace [YourSite] with your actual site name.

# ============================================================
# Script 1: Move Templates to Helix Structure
# Moves templates from User Defined to Project/Feature/Foundation
# Safe to re-run - merges fields if template already exists at destination
# ============================================================

# Input Parameters
# Replace with your own template IDs
$templateIds = @(
 "{YOUR-TEMPLATE-ID-HERE}"
)

# Update these paths to match your site
$sourcePath = "/sitecore/templates/User Defined/[YourSite]"
$destinationPath = "/sitecore/templates/Project/[YourSite]"

$sourceRoot = Get-Item -Path $sourcePath
$destinationRoot = Get-Item -Path $destinationPath

if ($null -eq $sourceRoot) {
 Write-Error "Source folder not found at path $sourcePath"
 return
}

if ($null -eq $destinationRoot) {
 Write-Error "Destination folder not found at path $destinationPath"
 return
}

function Ensure-DestinationPath {
 param (
  [Parameter(Mandatory = $true)]
  [Sitecore.Data.Items.Item]$sourceItem,

  [Parameter(Mandatory = $true)]
  [Sitecore.Data.Items.Item]$destinationParent
 )

 $destinationItem = Get-ChildItem -ID $destinationParent.ID | Where-Object {
  $_.Name -eq $sourceItem.Name
 }

 if ($null -eq $destinationItem) {
  $destinationItem = New-Item -Path $destinationParent.Paths.FullPath -Name $sourceItem.Name -ItemType $sourceItem.TemplateID
 }

 return $destinationItem
}

foreach ($templateId in $templateIds) {
 $templateItem = Get-Item -Path 'master:' -ID ([Sitecore.Data.ID]$templateId)

 if ($templateItem.Paths.FullPath.StartsWith($destinationPath)) {
  Write-Warning "Template $templateId already at destination - merging fields."

  $fullPath = $templateItem.Paths.FullPath
  $modifiedPath = $fullPath -replace [regex]::Escape($destinationPath), ''
  $sourceItemPath = 'master:' + $sourcePath + $modifiedPath

  if (Test-Path -Path $sourceItemPath) {
   $sourceItem = Get-Item -Path $sourceItemPath
   $targetItem = $templateItem

   $oldTemplateSections = $sourceItem.Axes.GetDescendants() | Where-Object {
    $_.TemplateName -eq 'Template section'
   }

   $newTemplateSections = $targetItem.Axes.GetDescendants() | Where-Object {
    $_.TemplateName -eq 'Template section'
   }

   foreach ($oldSection in $oldTemplateSections) {
    $newSection = $newTemplateSections | Where-Object {
     $_.Name -eq $oldSection.Name
    }

    if (-not $newSection) {
     $newSection = New-Item -Path ($targetItem.Paths.FullPath + '/' + $oldSection.Name) `
      -ItemType '/sitecore/templates/System/Templates/Template section'

     Write-Host "Added section '$($newSection.Name)'" -ForegroundColor Green
    }

    $oldFields = $oldSection.Children | Where-Object {
     $_.TemplateName -eq 'Template field'
    }

    $newFields = $newSection.Children | Where-Object {
     $_.TemplateName -eq 'Template field'
    }

    foreach ($oldField in $oldFields) {
     $target = $newFields | Where-Object {
      $_.Name -eq $oldField.Name
     }

     if (-not $target) {
      $target = New-Item -Path "$($newSection.Paths.FullPath)/$($oldField.Name)" `
       -ItemType '/sitecore/templates/System/Templates/Template field'

      Write-Host "Added field '$($oldField.Name)'" -ForegroundColor Green
     }

     $target.Editing.BeginEdit()

     try {
      $target["Type"] = $oldField["Type"]
      $target["Source"] = $oldField["Source"]
      $target["Shared"] = $oldField["Shared"]
      $target["Unversioned"] = $oldField["Unversioned"]
      $target["Blob"] = $oldField["Blob"]
      $target["Default value"] = $oldField["Default value"]
      $target["Validation"] = $oldField["Validation"]
      $target["Validation Text"] = $oldField["Validation Text"]

      Write-Host "Updated field '$($oldField.Name)'" -ForegroundColor Cyan
     }
     finally {
      $target.Editing.EndEdit()
     }
    }
   }

   Remove-Item -Path ('master:' + $sourceItem.Paths.FullPath) -Permanently -Force -Recurse
   Write-Host 'Old template removed after merge.' -ForegroundColor Red
  }
  else {
   Write-Host "Source not found: $sourceItemPath"
  }
 }
 else {
  if ($null -eq $templateItem) {
   Write-Error "Template $templateId not found."
   continue
  }

  $currentSourceItem = $templateItem.Parent
  $relativePathStack = New-Object System.Collections.Stack

  while ($currentSourceItem.ID -ne $sourceRoot.ID) {
   $relativePathStack.Push($currentSourceItem)
   $currentSourceItem = $currentSourceItem.Parent

   if ($null -eq $currentSourceItem) {
    Write-Warning 'Reached null parent.'
    break
   }
  }

  $currentDestinationParent = $destinationRoot

  while ($relativePathStack.Count -gt 0) {
   $folder = $relativePathStack.Pop()

   $currentDestinationParent = Ensure-DestinationPath -sourceItem $folder `
    -destinationParent $currentDestinationParent
  }

  $existing = Get-ChildItem -Path 'master:' -ID $currentDestinationParent.ID | Where-Object {
   $_.Name -eq $templateItem.Name
  }

  if ($null -eq $existing) {
   Write-Output "Moving '$($templateItem.Name)' to '$($currentDestinationParent.Paths.FullPath)'"

   Move-Item -Path ('master:' + $templateItem.Paths.FullPath) `
    -Destination ('master:' + $currentDestinationParent.Paths.FullPath)
  }
  else {
   Write-Output "'$($templateItem.Name)' already exists at destination - skipping."
  }
 }
}

What makes this script particularly useful is that it's safe to re-run. If you discover a missed template later, just add its ID and run again, it will merge without creating duplicates.

 

Script 2: Adding SitecoreAI Base Templates

Once your templates are in the right place, they need to inherit the SitecoreAI out-of-the-box base templates. The starter kit ships with base templates that provide things like Page Design fields and SEO/Navigation metadata. If your page templates don't inherit from these, editors won't have access to those features.

This script handles that in bulk, appending the new base templates to each of your page templates without touching what's already there.

Customization Note: Replace the placeholder template IDs in $templateIds with your own page template IDs. Do not change $baseTemplateIds, those are SitecoreAI OOTB IDs.

# ============================================================
# Script 2: Add SitecoreAI Base Templates to Page Templates
# Appends OOTB base templates without overwriting existing ones
# ============================================================

function Add-BaseTemplatesToTemplates {
 param (
  [Parameter(Mandatory=$true)]
  [string[]]$TemplateIds,

  [Parameter(Mandatory=$true)]
  [string[]]$BaseTemplateIds
 )

 if ($TemplateIds.Count -eq 0 -or $BaseTemplateIds.Count -eq 0) {
  Write-Error 'Template IDs or Base Template IDs cannot be empty.'
  return
 }

 $report = @()

 foreach ($templateId in $TemplateIds) {
  try {
   $template = Get-Item -Path 'master:' -ID $templateId

   if ($null -eq $template) {
    Write-Warning "Template $templateId not found."
    continue
   }

   $template.Editing.BeginEdit()
   $currentBaseTemplates = $template["__Base template"]
   $template.Editing.EndEdit()

   $templatesToAdd = @()

   foreach ($baseId in $BaseTemplateIds) {
    $baseItem = Get-Item -Path 'master:' -ID $baseId

    if ($null -eq $baseItem) {
     Write-Warning "Base template $baseId not found."
     continue
    }

    if ($currentBaseTemplates -notcontains $baseId) {
     $templatesToAdd += $baseId
    }
   }

   if ($templatesToAdd.Count -gt 0) {
    $newBaseTemplates = $currentBaseTemplates + '|' + ($templatesToAdd -join '|')

    $template.Editing.BeginEdit()
    $template["__Base template"] = $newBaseTemplates
    $template.Editing.EndEdit()

    $report += [PSCustomObject]@{
     TemplateId = $templateId
     TemplateName = $template.Name
     AddedBaseTemplates = $templatesToAdd
    }

    Write-Host "Updated: $($template.Name)" -ForegroundColor Green
   }
   else {
    Write-Host "No changes needed for: $($template.Name)"
   }
  }
  catch {
   Write-Error "Error processing $templateId : $_"
  }
 }

 return $report
}

# Configure below

# Replace with your own page template IDs
$templateIds = @(
 "{YOUR-PAGE-TEMPLATE-ID-1}",
 "{YOUR-PAGE-TEMPLATE-ID-2}"
)

# Do not change - these are SitecoreAI OOTB base template IDs
$baseTemplateIds = @(
 "{7D4E775A-290C-4EC6-89A9-2D19C32E8EC7}"
)

$changeReport = Add-BaseTemplatesToTemplates -TemplateIds $templateIds `
 -BaseTemplateIds $baseTemplateIds

$changeReport | Format-Table

Pro Tip: Mark your base template IDs with a 'Do not change' comment directly in the script. When you're deep in a migration and moving fast, it's easy to accidentally swap these out.

 

Script 3: Bulk Package Installation

This one isn't glamorous, but it solves a real annoyance. If you're bringing content over from XP via Sitecore packages, a common approach for incremental migrations, you probably have a lot of them. Installing each one manually through the Package Installer UI, waiting, checking, moving to the next... it adds up fast. And if one fails quietly, you might not catch it until much later.

Note: Sitecore has moved away from content packages. Instead, it is recommended to use Content API or Serialization to move/install content.

This script can easily be integrated in CI/CD pipeline with serialization to automate the process.

This script installs a list of packages in sequence, logs each one with timestamps and status levels, continues past individual failures, and outputs a full summary at the end.

Customization Note: Replace the placeholder package names in $PackagePaths with your actual .zip file names. Ensure the packages are accessible from the Sitecore instance.

# ============================================================
# Script 3: Bulk Sitecore Package Installation
# Installs multiple packages with per-package error tracking
# and a full summary report at the end
# ============================================================

# Replace with your actual package file names
$PackagePaths = @(
 "YYYYMMDD.HHMM.[SiteName]-1.zip",
 "YYYYMMDD.HHMM.[SiteName]-2.zip"
)

function Write-Log {
 param(
  [Parameter(Mandatory=$true)]
  [string]$Message,

  [Parameter(Mandatory=$false)]
  [ValidateSet('Info','Warning','Error')]
  [string]$Level = 'Info'
 )

 $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
 $msg = "[$ts] [$Level] $Message"

 switch ($Level) {
  'Info' {
   Write-Host $msg -ForegroundColor Cyan
  }
  'Warning' {
   Write-Host $msg -ForegroundColor Yellow
  }
  'Error' {
   Write-Host $msg -ForegroundColor Red
  }
 }
}

function Install-SitecorePackage {
 param(
  [Parameter(Mandatory=$true)]
  [string]$PackagePath
 )

 try {
  Write-Log "Installing: $PackagePath" -Level Info

  $result = Install-Package -Path $PackagePath -ErrorAction Stop

  if ($result.Status -eq 'Success') {
   Write-Log "Success: $PackagePath" -Level Info
   return $true
  }
  else {
   throw "Failed with status: $($result.Status)"
  }
 }
 catch {
  Write-Log "Error installing $PackagePath : $($_.Exception.Message)" -Level Error
  return $false
 }
}

function Start-PackageInstallation {
 $success = 0
 $failed = 0
 $total = $PackagePaths.Count

 Write-Log "Starting bulk install. Total: $total" -Level Info

 foreach ($pkg in $PackagePaths) {
  if (Install-SitecorePackage -PackagePath $pkg) {
   $success++
  }
  else {
   $failed++
   Write-Log 'Continuing after failure.' -Level Warning
  }
 }

 Write-Log 'Package Installation Summary:' -Level Info
 Write-Log "Total: $total" -Level Info
 Write-Log "Success: $success" -Level Info
 Write-Log "Failed: $failed" -Level Info
}

Start-PackageInstallation

Pro Tip: When running 30+ packages at midnight before a go-live, that final summary is the difference between confidence and guesswork.

 

Up Next: The Hard Part

Scripts 1 through 3 are foundational, they get your templates clean and your content imported. But the real heavy lifting is still ahead: taking all those items that got dumped in the wrong places and restructuring them into what SitecoreAI expects.

In Part 2, I'll cover the content restructuring script, the one that walks your entire content tree, identifies non-page items, creates Data folders where needed, and moves everything into the right place. It's the most complex script in the set, and honestly the most satisfying to watch run.

Stay tuned.

Building something similar or hitting a wall on your own migration? I'd love to hear what you're running into, feel free to reach out.