- Xperience by Kentico
How to Migrate Content from Kentico CMS Portal Engine to Xperience by Kentico
In this article, I'll guide you through the process of migrating page content between Kentico CMS Portal Engine and Xperience by Kentico sites.
👋 Introduction
Kentico CMS 12 was the last version of Kentico’s product that included the beloved Portal Engine 🥰 development model. As websites on this version become obsolete, clients are increasingly seeking site redesigns and Kentico upgrades. Currently, the go-to product is Xperience by Kentico (XbyK), which is based on the MVC development model. Unfortunately, there is virtually no compatibility between the old and new versions, meaning that redesigns and upgrades require a complete site rebuild 🧱.
Xperience by Kentico Migration Tooling
One of the first tasks during such a site rebuild is content migration. Kentico has created a set of migration paths and tools: the Xperience by Kentico Migration Toolkit, that may help you migrate your site. These tools handle the complex task of migrating various types of objects using a 1:1 approach from the old Kentico instances to XbyK.
While migrating pages and their data, I had concerns about the outcome from a content modeling perspective. In the Portal Engine, content is stored directly within pages. However, Xperience by Kentico recommends storing content in reusable content items and linking them to pages, which now serve primarily as a medium for displaying content. This is something the Migration tool cannot do. However, it can be very useful when migrating other objects, such as users.
Disclaimer
The migration mentioned below in this article was completed in April 2024, and since then, Kentico has been diligently working on enhancing migration paths and the Migration Toolkit. Be sure to check it out! As of August 2024, I've noticed that there are now options for structured content-only migration in the Portal Engine. However, when I recently revisited the Migration Toolkit to migrate a Kentico 11 site, I encountered a roadblock due to some issues. So, it's important to be patient with the process.
Or, if you prefer a hands-on approach, you can follow along with this article to design your own migration path.
Migration Experience on a Client Project
We went through a site rebuild going from Portal Engine v12 to XbyK. We wanted to migrate 1:1 a subset of site data, like news and their media assets, while also restructuring the site, refreshing the visuals, and modeling content types as recommended by Kentico’s documentation and representatives. We had a clear vision of how things should look in XbyK.
Challenges with Recommended Tools
We were recommended to use a migration path that involved KX13 as a proxy. The idea of using KX13 as a proxy felt a bit crazy to me.
However, we needed to get the job done, so I put effort into that. Regrettably, I gave up after reviewing the process we would have to go through. I became overwhelmed by the complexity. Additionally, since we knew how the result should look in XbyK, we were not sure what the outcome of the migration process would be as it attempts to migrate the site as a whole.
To manage content modeling effectively and maintain full control, I decided to choose my own content migration path. Instead of attempting to migrate the entire site in one go, I opted to create migration scripts only for pages that would require significant effort to migrate manually, such as news pages, of which my client has hundreds 💯.
Final Approach
In the end, we decided to avoid Kentico’s migration tooling. Instead of learning and utilizing dedicated migration tools, we invested our time in creating the XbyK site from scratch the right way and migrating only the data we needed directly from Portal Engine to XbyK, ensuring we had complete control over the end result.
In Portal Engine, I created endpoints exposing the data as JSON. In XbyK, I created controllers that consume these endpoints and store data using the XbyK API. It was quite simple in the end and beneficial, as I was investing my time in learning XbyK properly rather than migration tools.
Further in this article, I will share technical details about the data migration path we took on a simplified example.
Source code is also available as a Github gist.
🧠 Mental Model Behind the Migration
Let’s imagine we have a Portal Engine site. Among other content, we have numerous news pages, each with its content stored under the Form tab.
When migrating to Xperience by Kentico, we aim to have the content fields (Title
, Date
, and Text
) stored in a reusable content item inside the Content Hub. This reusable content item would then be linked to a page inside a Website channel.
Transferring data from one location to another is efficiently done using JSON serialized data. This process involves two primary steps:
- Serialize page data into JSON and expose through a URL in the Portal Engine.
- Read and deserialize the JSON data in Xperience by Kentico, then store it to achieve the desired result.
This article will guide you through these steps.
🤖 Serialize Page Data into JSON in Portal Engine
To expose page data as JSON in the Portal Engine, we use a Custom Response Repeater web part. While I've previously written about this web part here, I believe a tailored explanation for our current situation is beneficial.
Import the Custom Response Repeater Web Part
The Custom Response Repeater web part isn't included with Kentico by default, so we need to import ⬇️ it into a project. You can find the import process described here. I've prepared import packages for versions 10 and 12:
With some effort, we can modify the package to be compatible with other Portal Engine-based versions. When we unzip the package, there are metadata files stating the Kentico version that you can modify. There might also be minor updates in the web part implementation as Kentico may have introduced API changes between versions.
Rendering JSON Data
After successfully importing the web part, we can use it to render JSON data. In our Portal Engine site, we go to the Pages application, create a new page with an empty page template, and place the Custom Response Repeater web part inside a web part zone.
Now, it's time to set up the web part properties and create a transformation. The Custom Response Repeater web part is like any other Repeater web part in Kentico, with the added ability to define Content type and other properties for the page response.
The final step is to reveal the transformation.
Besides the obvious page type fields Title
, Date
, and Text
, we also expose DocumentID
and NodeAlias
, which are system fields that we will use in the migration script to build unique names and URL slugs. Title
and Text
fields in the transformation use additional replace methods to render valid JSON string values. Here is the code:
{
"DocumentID": {% DocumentID %},
"NodeAlias": "{% NodeAlias %}",
"Title": "{% Replace(RegexReplace(Title, @"\t|\n|\r", ""), "\"", "\\\"") %}",
"Date": "{% Date %}",
"Text": "{% Replace(RegexReplace(Text, @"\t|\n|\r", ""), "\"", "\\\"") %}",
}{% DataItemCount - DataItemIndex == 1 ? "" : ", " %}
After saving the transformation and the web part, we can request the page on the live site and receive a valid JSON response.
Excellent 🎉! We've serialized our news pages data into JSON output and exposed it through a URL. We will use this as input for a migration script that will create pages and reusable content items in our target Xperience by Kentico site.
🫗 Import data in Xperiece by Kentico
As I mentioned earlier, our primary task is to create a migration script. However, before we proceed, we need to ensure a few prerequisites are met. Firstly, we must have an existing parent News page, under which the migrated pages will reside. Also, please note the Page ID
value which we will use later in our migration script.
Secondly, we need to have prepared content types and models generated in the codebase for the News pages that will be migrated.
Content type for News reusable content item that will be linked to News page:
Content type for News page:
📜 The Migration Script
The migration script, which will deserialize the JSON and create pages and reusable content items on our site, will be managed by a controller within our Xperience by Kentico codebase. I recommend running the migration locally to avoid potential timeouts when migrating a large amount of content.
The logic of the migration script is as follows:
- Request the URL where our JSON data resides and deserialize it into a list of News, based on a Data Transfer Object model.
- Iterate through the list and for each item:
- Generate unique code name identifiers for the page and reusable content item.
- If a page or a reusable content item with such names already exist (when we repeat the migration), delete them.
- Parse incoming data into appropriate data types.
- Create a reusable content item.
- Obtain a reference to the reusable content type.
- Create a page using the reference, attach the page to the parent News page by its Page ID, and assign it a URL slug.
The solution is comprised of three files:
NewsDtoModel.cs
- This file defines the type for the deserialized News item.NewsMigrationController.cs
- This file implements the core migration logic as described above.MigrationHelper.cs
- This file contains methods that can be reused across multiple migration scripts.
The NewsDtoModel.cs
file
public class NewsItemDto
{
public int DocumentID { get; set; }
public string NodeAlias { get; set; }
public string Title { get; set; }
public string Date { get; set; }
public string Text { get; set; }
}
The NewsMigrationController.cs
file
using System.Text.Json;
using CMS.ContentEngine;
using CMS.Helpers;
using CMS.Membership;
using CMS.Websites;
using CMS.Websites.Routing;
using Kentico.Content.Web.Mvc.Routing;
using Microsoft.AspNetCore.Mvc;
using Project.Helpers;
namespace Project.Controllers;
public class NewsMigrationController : Controller
{
private readonly IWebPageManager webPageManager;
private readonly IContentQueryExecutor contentQueryExecutor;
private readonly MigrationHelper migrationHelper;
private readonly IContentItemManager contentItemManager;
private readonly IPreferredLanguageRetriever currentLanguageRetriever;
public NewsMigrationController(
IContentQueryExecutor contentQueryExecutor,
IWebPageManagerFactory webPageManagerFactory,
IUserInfoProvider userInfoProvider,
IWebsiteChannelContext websiteChannelContext,
MigrationHelper migrationHelper,
IContentItemManagerFactory contentItemManagerFactory,
IPreferredLanguageRetriever currentLanguageRetriever
)
{
UserInfo user = userInfoProvider.Get("Administrator");
webPageManager = webPageManagerFactory.Create(websiteChannelContext.WebsiteChannelID, user.UserID);
this.contentQueryExecutor = contentQueryExecutor;
this.migrationHelper = migrationHelper;
contentItemManager = contentItemManagerFactory.Create(user.UserID);
this.currentLanguageRetriever = currentLanguageRetriever;
}
/// <summary>
/// Handles the HTTP POST request for news migration.
/// </summary>
/// <returns>The result of the migration.</returns>
[HttpPost("migration/news")]
public async Task<IActionResult> Index()
{
// Request the URL where our JSON data resides and deserialize it into a list of News, based on a Data Transfer Object model
var response = await migrationHelper.GetMigrationData("https://www.project.com/Export/News-export");
if (!response.IsSuccessStatusCode)
{
return StatusCode((int)response.StatusCode);
}
var responseStream = await response.Content.ReadAsStreamAsync();
var data = await JsonSerializer.DeserializeAsync<List<NewsItemDto>>(responseStream);
int newsPageId = 324;
// Iterate through the list and for each item
foreach (var item in data)
{
// Generate unique code name identifiers for the page and reusable content item
string pageName = $"MigratedNewsItem{item.DocumentID}";
string contentItemName = $"MigratedContentItemNewsItem{item.DocumentID}";
// If items with such names already exist (when we repeat the migration), delete them
await migrationHelper.DeletePageIfExists(NewsItemPage.CONTENT_TYPE_NAME, pageName);
await migrationHelper.DeleteContentItemIfExists(NewsItemReusableContent.CONTENT_TYPE_NAME, contentItemName);
// Parse incoming data into appropriate data types.
DateTime date;
DateTime.TryParse(item.Date, out date);
string pageDisplayName = item.Title.Truncate(100);
var languageName = currentLanguageRetriever.Get();
// Create a reusable content item
CreateContentItemParameters createParams = new CreateContentItemParameters(
NewsItemReusableContent.CONTENT_TYPE_NAME,
contentItemName,
pageDisplayName,
languageName
);
var contentItemData = new ContentItemData(new Dictionary<string, object>{
{ "Title", item.Title },
{ "Date", date },
{ "Text", item.Text }
});
var id = await contentItemManager.Create(createParams, contentItemData);
await contentItemManager.TryPublish(id, languageName);
var builder = new ContentItemQueryBuilder();
builder.ForContentType(NewsItemReusableContent.CONTENT_TYPE_NAME, subqueryConfiguration =>
{
subqueryConfiguration
.TopN(1)
.Where(where => where.WhereEquals("ContentItemID", id));
});
// Obtain a reference to the reusable content type
NewsItemReusableContent contentItem =
(await contentQueryExecutor.GetMappedResult<NewsItemReusableContent>(builder)).FirstOrDefault();
IEnumerable<ContentItemReference> itemList =
[
new ContentItemReference
{
Identifier = contentItem.SystemFields.ContentItemGUID
},
];
// Create a page using the reference, attach the page to the parent News page by its Page ID, and assign it a URL slug.
var itemData = new ContentItemData(new Dictionary<string, object>{
{"Item", itemList }
});
var contentItemParameters = new ContentItemParameters(NewsItemPage.CONTENT_TYPE_NAME, itemData);
var createPageParameters = new CreateWebPageParameters(pageName, pageDisplayName, languageName, contentItemParameters)
{
ParentWebPageItemID = newsPageId,
UrlSlug = item.NodeAlias
};
int webPageItemId = await webPageManager.Create(createPageParameters);
await webPageManager.TryPublish(webPageItemId, languageName);
}
return Ok();
}
}
The MigrationHelper.cs
file
using CMS.ContentEngine;
using CMS.MediaLibrary;
using CMS.Membership;
using CMS.Websites;
using CMS.Websites.Routing;
using Kentico.Content.Web.Mvc;
using CMS.DataEngine;
using Kentico.Content.Web.Mvc.Routing;
namespace Project.Helpers;
/// <summary>
/// Represents a helper class for migration operations.
/// </summary>
public class MigrationHelper
{
private readonly IContentQueryExecutor contentQueryExecutor;
private readonly IWebPageManager webPageManager;
private readonly IContentItemManager contentItemManager;
private readonly IHttpClientFactory clientFactory;
private readonly IPreferredLanguageRetriever currentLanguageRetriever;
private readonly IWebsiteChannelContext websiteChannelContext;
public MigrationHelper(
IContentQueryExecutor contentQueryExecutor,
IWebPageManagerFactory webPageManagerFactory,
IWebsiteChannelContext websiteChannelContext,
IUserInfoProvider userInfoProvider,
IContentItemManagerFactory contentItemManagerFactory,
IHttpClientFactory clientFactory,
IPreferredLanguageRetriever currentLanguageRetriever
)
{
UserInfo user = userInfoProvider.Get("Administrator");
webPageManager = webPageManagerFactory.Create(websiteChannelContext.WebsiteChannelID, user.UserID);
contentItemManager = contentItemManagerFactory.Create(user.UserID);
this.contentQueryExecutor = contentQueryExecutor;
this.clientFactory = clientFactory;
this.currentLanguageRetriever = currentLanguageRetriever;
this.websiteChannelContext = websiteChannelContext;
}
/// <summary>
/// Gets the migration data from the specified endpoint.
/// </summary>
/// <param name="endpoint">The endpoint URL.</param>
/// <returns>The HTTP response message.</returns>
public async Task<HttpResponseMessage> GetMigrationData(string endpoint)
{
var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
var client = clientFactory.CreateClient();
var response = await client.SendAsync(request);
return response;
}
/// <summary>
/// Deletes a page if it exists.
/// </summary>
/// <param name="contentType">The content type of the page.</param>
/// <param name="codename">The codename of the page.</param>
public async Task DeletePageIfExists(string contentType, string codename)
{
var languageName = currentLanguageRetriever.Get();
var builder = new ContentItemQueryBuilder()
.ForContentType(contentType, config =>
{
config
.TopN(1)
.ForWebsite(websiteChannelContext.WebsiteChannelName)
.Where(where => where.WhereEquals(nameof(IWebPageContentQueryDataContainer.WebPageItemName), codename));
})
.InLanguage(languageName);
var pageId = await contentQueryExecutor
.GetWebPageResult(builder: builder, resultSelector: rowData =>
{
var pageRowId = rowData.WebPageItemID;
return pageRowId;
});
if (pageId.Any())
{
await webPageManager.Delete(pageId.First(), languageName);
}
}
/// <summary>
/// Deletes a content item if it exists.
/// </summary>
/// <param name="contentType">The content type of the item.</param>
/// <param name="codename">The codename of the item.</param>
public async Task DeleteContentItemIfExists(string contentType, string codename)
{
var languageName = currentLanguageRetriever.Get();
var builder = new ContentItemQueryBuilder()
.ForContentType(contentType, config =>
{
config
.TopN(1)
.Where(where => where.WhereEquals(nameof(IContentItemFieldsSource.SystemFields.ContentItemName), codename));
})
.InLanguage(languageName);
var contentItemId = await contentQueryExecutor
.GetResult(builder: builder, resultSelector: rowData =>
{
var contentItemId = rowData.ContentItemID;
return contentItemId;
});
if (contentItemId.Any())
{
await contentItemManager.Delete(contentItemId.First(), languageName);
}
}
}
⚙️ Executing the Migration
Once everything is set up, we can run our site and initiate the migration script by making a POST
request to the URL http://localhost:<port>/migration/news
. Depending on the volume of data being migrated, it may take some time ⏱️. After completion, the page data from our Portal Engine site should be migrated to our Xperience by Kentico site.
Conclusion
I acknowledge that the migration process can be daunting 😅. In this article, I've detailed the approach I adopted for a client's project, using a simplified News pages scenario as an example. Your situation may differ, necessitating adjustments to align with your specific needs. Nonetheless, I hope this article offers a comprehensive insight into the process and code, serving as a valuable guide and source of inspiration for your migration journey. Or you can ask me of course 🙃.
And finally, if you need to migrate images from the Portal Engine Media Library to Xperience by Kentico, check out my follow-up article.
About the author
Milan Lund is a Freelance Web Developer with Kentico Expertise. He specializes in building and maintaining websites in Xperience by Kentico. Milan writes articles based on his project experiences to assist both his future self and other developers.
Find out more