Create product page wrappers for Content hub products

When your content editors create or localize a product in your product catalog, they typically expect it to appear in your main website channel. However, if you’ve chosen to store products in Content hub, as in the Training guides example, this doesn’t happen out-of-the-box.

Let’s explore how to build a handler that automatically synchronizes your reusable product items with pages on your website channel.

We’ll implement event-driven wrapper creation, language variant handling, publishing/unpublishing behavior, parent-page orchestration within the store section, and the content retrieval methods needed to run all of this without website channel context.

Changes like these in the content hub… Screenshot of a product item being saved in the Content hub

Will instantly affect the website channel:

Screenshot of an automatically created page wrapper

Before you start

This guide requires the following:

Code samples

You can find a project with completed, working versions of code samples from this guide and others in the finished branch of the Training guides repository.

The main branch of the repository provides a starting point to code along with the guides.

The code samples in this guide are for .NET 8 only.

They come from a project that uses implicit using directives. You may need to add additional using directives to your code if your project does not use this feature.

Create and register an event handler module

In the ~/Features/Commerce/EventHandlers folder of the TrainingGuides.Web project, create a ProductPageWrapperHandler class that inherits from Module, and register it with the RegisterModule assembly attribute so Kentico initializes it at startup.

Add constants up front for values that you will reuse throughout the implementation: module name, admin username, store path, channel name, and channel GUID.

C#
ProductPageWrapperHandler.cs - Registration and constants

using CMS;
using CMS.ContentEngine;
using CMS.Core;
using CMS.DataEngine;
using CMS.Membership;

using Kentico.PageBuilder.Web.Mvc;

using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

using TrainingGuides.ProductStock;
using TrainingGuides.Web.Commerce.EventHandlers;
using TrainingGuides.Web.Features.Commerce.Products.Widgets.ProductListing;
using TrainingGuides.Web.Features.Commerce.Products.Widgets.ProductWidget;
using TrainingGuides.Web.Features.Shared.Logging;
using TrainingGuides.Web.Features.Shared.OptionProviders.ColorScheme;
using TrainingGuides.Web.Features.Shared.OptionProviders.ColumnLayout;
using TrainingGuides.Web.Features.Shared.OptionProviders.CornerStyle;
using TrainingGuides.Web.Features.Shared.Sections.General;
using TrainingGuides.Web.Features.Shared.Services;

[assembly: RegisterModule(typeof(ProductPageWrapperHandler))]

namespace TrainingGuides.Web.Commerce.EventHandlers;

public class ProductPageWrapperHandler() : Module(MODULE_NAME)
{
    // Name for initializing the module
    public const string MODULE_NAME = "Product page wrapper handlers";

    // Username of an admin user
    private const string ADMIN = "administrator";

    // Path to the store section of the site
    private const string STORE_PATH = "/Store";

    // Name of the website channel
    private const string CHANNEL_NAME = "TrainingGuidesPages";

    // GUID of the website channel (Note this is the value of WebsiteChannelGUID, not ChannelGUID)
    private const string WEB_CHANNEL_GUID = "FDBA40FE-1ECE-4821-9D57-EAA1D89E13B1";
}

Resolve dependencies and subscribe to content events

Override the Module class’s OnInit method and resolve the following services from parameters.Services:

  • IWebPageManagerFactory
  • IContentItemRetrieverService
  • IInfoProvider<UserInfo>
  • IInfoProvider<WebsiteChannelInfo>
  • ILogger<ProductPageWrapperHandler>

Use the info providers to resolve the admin user and website channel, and create an IWebPageManager from their IDs.

Subscribe to the following content item lifecycle events (we’ll implement the handler methods in later steps):

  • ContentItemEvents.Create.After
  • ContentItemEvents.CreateLanguageVariant.After
  • ContentItemEvents.Delete.Execute
  • ContentItemEvents.Publish.Execute
  • ContentItemEvents.Unpublish.Execute

Based on these events, we can implement the behavior to automatically manage page wrappers.

C#
ProductPageWrapperHandler.cs - OnInit event wiring

...
    // We are setting these to default! to avoid a compiler warning.
    // We know these will be initialized in the OnInit method, instead of the typical constructor DI pattern.
    // This is a known limitation of the Module base class and does not indicate actual null safety issues in this code.
    private IWebPageManagerFactory webPageManagerFactory = default!;
    private IWebPageManager webPageManager = default!;
    private IContentItemRetrieverService contentItemRetrieverService = default!;
    private IInfoProvider<UserInfo> userInfoProvider = default!;
    private IInfoProvider<WebsiteChannelInfo> websiteChannelInfoProvider = default!;
    private ILogger<ProductPageWrapperHandler> logger = default!;

    // Contains initialization code that is executed when the application starts
    protected override void OnInit(ModuleInitParameters parameters)
    {
        base.OnInit();

        webPageManagerFactory = parameters.Services.GetRequiredService<IWebPageManagerFactory>();
        contentItemRetrieverService = parameters.Services.GetRequiredService<IContentItemRetrieverService>();
        userInfoProvider = parameters.Services.GetRequiredService<IInfoProvider<UserInfo>>();
        websiteChannelInfoProvider = parameters.Services.GetRequiredService<IInfoProvider<WebsiteChannelInfo>>();
        logger = parameters.Services.GetRequiredService<ILogger<ProductPageWrapperHandler>>();

        var user = userInfoProvider.Get()
            .WhereEquals(nameof(UserInfo.UserName), ADMIN)
            .FirstOrDefault();

        var webChannel = websiteChannelInfoProvider.Get()
            .WhereEquals(nameof(WebsiteChannelInfo.WebsiteChannelGUID), new Guid(WEB_CHANNEL_GUID))
            .FirstOrDefault();

        webPageManager = webPageManagerFactory.Create(webChannel?.WebsiteChannelID ?? 0, user?.UserID ?? 0);

        // Suppress CS8622: Kentico's event system delegates have nullability attribute mismatches with our handler signatures.
        // This is a known framework limitation and does not indicate actual null safety issues in this code.
#pragma warning disable CS8622 // Nullability of reference types in type of parameter doesn't match the target delegate (possibly because of nullability attributes).
        ContentItemEvents.Create.After += ContentItem_Create_After;
        ContentItemEvents.CreateLanguageVariant.After += ContentItem_CreateLanguageVariant_After;
        ContentItemEvents.Delete.Execute += ContentItem_Delete_Execute;
        ContentItemEvents.Publish.Execute += ContentItem_Publish_Execute;
        ContentItemEvents.Unpublish.Execute += ContentItem_Unpublish_Execute;
#pragma warning restore CS8622 // Nullability of reference types in type of parameter doesn't match the target delegate (possibly because of nullability attributes).
    }

These events are synchronous, so we will need to use blocking calls for async code. In most use cases, this should have a fairly low risk of deadlocks, as the events only fire for changes to product items in the Content hub, and do not fire for changes to product stock from live-site transactions.

At the time of writing, async support for content item events is in development, and should be released within a couple of months.

Limit the handler to product content types

By default, these events fire for every reusable content item, regardless of whether or not it is a product. Let’s add some safeguards to make sure our functionality only applies to products.

Implement a method called GetApplicableTypeNames to discover product content types dynamically. Use reflection to search the TrainingGuides.Entities assembly for concrete classes that implement IProductSchema but not IProductVariantSchema, and collect each class’s CONTENT_TYPE_NAME constant.

Then implement a boolean IsApplicableType method that uses GetApplicableTypeNames to check if a provided content type meets the criteria.

C#
ProductPageWrapperHandler.cs - Type filtering guard

/// <summary>
/// Retrieves the content type names of all product types that should have a page wrapper.
/// This is determined by finding all classes that implement IProductSchema but not IProductVariantSchema, and retrieving the value of their CONTENT_TYPE_NAME constant.
/// </summary>
/// <returns>A list of content type names</returns>
/// <remarks>
/// Make sure the content type of the page wrapper does not meet the criteria defined in this method, otherwise it will cause an infinite loop of page creation.
/// </remarks>
private IEnumerable<string> GetApplicableTypeNames()
{
    // We know this class is stored in the Entities project, so we can use it to access the assembly
    var entitiesAssembly = typeof(ProductAvailableStockInfo).Assembly;
    var types = entitiesAssembly
        .GetTypes()
        .Where(type =>
            type.IsClass
            && !type.IsAbstract
            && typeof(IProductSchema).IsAssignableFrom(type)
            && !typeof(IProductVariantSchema).IsAssignableFrom(type));

    return types.Select(type =>
    {
        var field = type.GetField("CONTENT_TYPE_NAME");
        if (field is not null)
        {
            // The field is a constant, so we don't need an instance to retrieve its value - we can use null instead
            return field.GetValue(null) as string;
        }
        // If there is no CONTENT_TYPE_NAME constant, we skip this type
        return null;
    })
    // Filter out the classes with no CONTENT_TYPE_NAME constant
    .Where(name => name is not null)!;
}

/// <summary>
/// Determines whether the specified content type name is applicable for page wrapper creation.
/// </summary>
/// <param name="contentTypeName">Name of the content type to evaluate</param>
/// <returns>True if a page wrapper should be created for the specified content type</returns>
private bool IsApplicableType(string contentTypeName)
{
    var types = GetApplicableTypeNames();
    return types.Contains(contentTypeName);
}

Add content retrieval methods that do not rely on channel context

While handling page wrappers for products, we’ll need to query existing pages in a few cases, for example, to find a parent Store section page under which to create a product page.

However, event handlers run outside normal web channel context, so they cannot rely on available channel and language values used by ContentRetriever.

To solve that, add two explicit methods to IContentItemRetrieverService and ContentItemRetrieverService:

  • RetrieveWebPageByPathWithoutContext<T> to retrieves a single page by path
  • RetrieveWebPageChildrenByPathWithoutContext to retrieve child pages by parent path

Design both methods to accept channelName, languageName, forPreview, and includeSecuredItems where relevant.

These methods become the retrieval foundation for locating store sections and existing wrapper pages during event processing.

C#
~/Features/Shared/Services/IContentItemRetrieverService.cs

...
/// <summary>
/// Retrieves a web page content item by path without channel context using content item query API
/// </summary>
/// <typeparam name="T">Type of the web page to retrieve</typeparam>
/// <param name="pathToMatch">Path where the web page lives</param>
/// <param name="languageName">Name of the language</param>
/// <param name="channelName">Name of the channel</param>
/// <param name="forPreview">Indicates whether the retrieval is for preview (latest draft of items)</param>
/// <param name="includeSecuredItems">Indicates whether to include secured items</param>
/// <returns>The web page content item of the specified type</returns>
Task<T?> RetrieveWebPageByPathWithoutContext<T>(
    string pathToMatch,
    string languageName,
    string channelName,
    bool forPreview,
    bool includeSecuredItems)
    where T : IWebPageFieldsSource, new();

/// <summary>
/// Retrieves child pages of a given web page without channel context using content item query API
/// </summary>
/// <param name="contentTypeNames">Content types of the child pages to retrieve</param>
/// <param name="parentPagePath">Path of the parent page</param>
/// <param name="customContentTypeQueryParameters">Type-level filtering through <see cref="ContentTypesQueryParameters"/></param>
/// <param name="customContentQueryParameters">Query level filtering through <see cref="ContentQueryParameters"/></param>
/// <param name="forPreview">Indicates whether the retrieval is for preview (latest draft of items)</param>
/// <param name="includeSecuredItems">Indicates whether to include secured items</param>
/// <param name="channelName">Name of the channel</param>
/// <param name="languageName">Name of the language. If null, all languages are retrieved.</param>
/// <param name="depth">The maximum level of recursively linked content items to include in the results. Default is 1</param>
/// <returns>A collection of web pages that exist under the specified path in the content tree</returns>
Task<IEnumerable<IWebPageFieldsSource>> RetrieveWebPageChildrenByPathWithoutContext(
    IEnumerable<string> contentTypeNames,
    string parentPagePath,
    Func<ContentTypesQueryParameters, ContentTypesQueryParameters> customContentTypeQueryParameters,
    Func<ContentQueryParameters, ContentQueryParameters> customContentQueryParameters,
    bool forPreview,
    bool includeSecuredItems,
    string channelName,
    string? languageName = null,
    int depth = 1);
...

Implement RetrieveWebPageByPathWithoutContext<T>

This method retrieves one page by its exact path for a specific channel/language without using IWebsiteChannelContext. This will help us locate the Store section of the site when we create parent pages to organize our product wrappers.

Define a ContentItemQueryBuilder, scope it to the target website, using PathMatch.Single to find the page. Return the first result after executing the query.

C#
~/Features/Shared/ContentItemRetrieverService.cs

...
/// <inheritdoc />
public async Task<T?> RetrieveWebPageByPathWithoutContext<T>(
    string pathToMatch,
    string languageName,
    string channelName,
    bool forPreview,
    bool includeSecuredItems)
    where T : IWebPageFieldsSource, new()
{
    var builder = new ContentItemQueryBuilder();

    builder.ForContentTypes(query =>
        {
            query.ForWebsite(channelName, PathMatch.Single(pathToMatch));
        })
        .InLanguage(languageName);

    var queryExecutorOptions = new ContentQueryExecutionOptions
    {
        ForPreview = forPreview,
        IncludeSecuredItems = includeSecuredItems
    };

    var pages = await contentQueryExecutor.GetMappedResult<T>(builder, queryExecutorOptions);

    return pages.FirstOrDefault();
}
...

Implement RetrieveWebPageChildrenByPathWithoutContext

This method retrieves child pages under a parent path while letting callers inject custom filtering logic. This will come in handy for checking if a product already has a page wrapper, and for identifying potential parent pages in the Store section.

Scope the query using PathMatch.Children and limit results by content type using OfContentType. Use WithContentTypeFields so we can optionally cast the results and access type-specific fields later. Allow callers to inject custom type-level and query-level filters through customContentTypeQueryParameters and customContentQueryParameters, and only apply .InLanguage when a language is explicitly provided.

C#
~/Features/Shared/ContentItemRetrieverService.cs

...
/// <inheritdoc />
public async Task<IEnumerable<IWebPageFieldsSource>> RetrieveWebPageChildrenByPathWithoutContext(
    IEnumerable<string> contentTypeNames,
    string parentPagePath,
    Func<ContentTypesQueryParameters, ContentTypesQueryParameters> customContentTypesQueryParameters,
    Func<ContentQueryParameters, ContentQueryParameters> customContentQueryParameters,
    bool forPreview,
    bool includeSecuredItems,
    string channelName,
    string? languageName = null,
    int depth = 1)
{
    Action<ContentTypesQueryParameters> contentTypesQueryParameters =
        config => customContentTypesQueryParameters(config
            .ForWebsite(channelName, PathMatch.Children(parentPagePath))
            .OfContentType(contentTypeNames.ToArray())
            .WithLinkedItems(depth)
            .WithContentTypeFields());

    var builder = new ContentItemQueryBuilder()
                        .ForContentTypes(contentTypesQueryParameters)
                        .Parameters(param => customContentQueryParameters(param));

    if (!string.IsNullOrEmpty(languageName))
    {
        builder.InLanguage(languageName);
    }

    var queryExecutorOptions = new ContentQueryExecutionOptions
    {
        ForPreview = forPreview,
        IncludeSecuredItems = includeSecuredItems
    };

    var pages = await contentQueryExecutor.GetMappedWebPageResult<IWebPageFieldsSource>(builder, queryExecutorOptions);

    return pages;
}
...

Set up Page Builder for the created pages

Add configuration helpers

When we create a page, such as a Product page wrapper or Store section parent page, it won’t have a page template or any Page Builder content by default. Since the Training guides implementation uses widgets to display product listing and details, let’s change that.

Create methods that generate Page Builder JSON for product wrapper pages (GetProductWidgetsConfiguration) and parent listing pages (GetParentWidgetsConfiguration), along with page template configuration JSON(GetPageTemplateConfiguration).

With these helpers in place, we can include a meaningful Page Builder setup for each page we create. Editors will not need to manually configure layout each time unless they want to change the default appearance or add more widgets.

C#
ProductPageWrapperHandler.cs - Page builder helper methods

/// <summary>
/// Creates Page Builder JSON for the product wrapper page, with a product widget configured to display the current product 
/// </summary>
/// <returns>JSON string representing the product page's Page Builder content</returns>
private string GetProductWidgetsConfiguration()
{
    var config = new EditableAreasConfiguration
    {
        EditableAreas =
        {
            new EditableAreaConfiguration
            {
                Identifier = "areaMain",
                Sections =
                {
                    new SectionConfiguration
                    {
                        Identifier = Guid.NewGuid(),
                        TypeIdentifier = ComponentIdentifiers.Sections.GENERAL,
                        Properties = new GeneralSectionProperties
                        {
                            ColorScheme = ColorSchemeOption.TransparentDark.ToString(),
                            CornerStyle = CornerStyleOption.Round.ToString(),
                            ColumnLayout = ColumnLayoutOption.OneColumn.ToString(),
                        },
                        Zones =
                        {
                            new ZoneConfiguration
                            {
                                Identifier = Guid.NewGuid(),
                                Name = "zoneMain",
                                Widgets =
                                {
                                    new WidgetConfiguration
                                    {
                                        Identifier = Guid.NewGuid(),
                                        TypeIdentifier = ComponentIdentifiers.Widgets.PRODUCT,
                                        Variants =
                                        {
                                            new WidgetVariantConfiguration
                                            {
                                                Identifier = Guid.NewGuid(),
                                                Properties = new ProductWidgetProperties
                                                {
                                                    DisplayCurrentPage = true,
                                                    ShowVariantSelection = true,
                                                    ShowVariantDetails = true,
                                                    ShowCallToAction = false,
                                                    CallToActionText = "View Product",
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    };

    return JsonConvert.SerializeObject(config, Formatting.None, GetSerializerSettings());
}

/// <summary>
/// Creates Page Builder JSON for the parent page of the product wrapper, with a product listing widget configured to display products that exist beneath it in the tree
/// </summary>
/// <returns>JSON string representing the parent page's Page Builder content</returns>
private string GetParentWidgetsConfiguration()
{
    var config = new EditableAreasConfiguration
    {
        EditableAreas =
        {
            new EditableAreaConfiguration
            {
                Identifier = "areaMain",
                Sections =
                {
                    new SectionConfiguration
                    {
                        Identifier = Guid.NewGuid(),
                        TypeIdentifier = ComponentIdentifiers.Sections.GENERAL,
                        Properties = new GeneralSectionProperties
                        {
                            ColorScheme = ColorSchemeOption.TransparentDark.ToString(),
                            CornerStyle = CornerStyleOption.Round.ToString(),
                            ColumnLayout = ColumnLayoutOption.OneColumn.ToString(),
                        },
                        Zones =
                        {
                            new ZoneConfiguration
                            {
                                Identifier = Guid.NewGuid(),
                                Name = "zoneMain",
                                Widgets =
                                {
                                    new WidgetConfiguration
                                    {
                                        Identifier = Guid.NewGuid(),
                                        TypeIdentifier = ComponentIdentifiers.Widgets.PRODUCT_LISTING,
                                        Variants =
                                        {
                                            new WidgetVariantConfiguration
                                            {
                                                Identifier = Guid.NewGuid(),
                                                Properties = new ProductListingWidgetProperties
                                                {
                                                    SecuredItemsDisplayMode = SecuredOption.PromptForLogin.ToString(),
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    };

    return JsonConvert.SerializeObject(config, Formatting.None, GetSerializerSettings());
}

/// <summary>
/// Creates JSON serializer settings for dealing with Page Builder JSON
/// </summary>
/// <returns>JSON serializer settings</returns>
private JsonSerializerSettings GetSerializerSettings() =>
    new()
    {
        ContractResolver = new CamelCasePropertyNamesContractResolver(),
        NullValueHandling = NullValueHandling.Include,
    };

/// <summary>
/// Creates page template configuration JSON for the product wrapper page and its parent page, using the General template with default properties
/// </summary>
/// <returns>JSON string representing the page template configuration</returns>
private string GetPageTemplateConfiguration() =>
    "{\"identifier\":\"TrainingGuides.GeneralPageTemplate\",\"properties\":null,\"fieldIdentifiers\":null}";

Enable Page Builder for the content types

Before moving on to page creation, make sure Page Builder is enabled for the page content types involved in this flow. If it is not enabled, wrapper pages may still be created, but they will not render the section/widget configuration provided by your helper methods.

C#
~/Program.cs

...
builder.Services
    .AddKentico(async features =>
    {
        features.UsePageBuilder(new PageBuilderOptions
        {
            DefaultSectionIdentifier = ComponentIdentifiers.Sections.SINGLE_COLUMN,
            RegisterDefaultSection = false,
            ContentTypeNames = new[] {
                ...
                ProductPage.CONTENT_TYPE_NAME,
                StoreSection.CONTENT_TYPE_NAME
            }
        });
...

Implement parent-page orchestration under /Store

Before we can implement the creation of Product page wrappers, we need to ensure they will have a valid parent location.

In the Training guides repository, Store section pages have a StoreSectionContentTypes field, referencing a specific reusable product content type. This field designates the Store section as the parent page for reusable items of the designated type to store their page wrappers.

Create parent pages and language variants

Store section pages should live under the ~/Store path in the content tree, so we will need a method to designate the Store page as their parent, alongside creation methods for pages and variants:

  • GetStorePageId to retrieve the /Store page ID
  • CreateParentPage to create a StoreSection parent page beneath the /Store page
  • CreateParentPageLanguageVariant to add missing language variants of the StoreSection parent

Build ContentItemData that assigns the provided content type GUID to the StoreSectionContentTypes field. For new pages, set ParentWebPageItemID using the GetStorePageId method.

C#
ProductPageWrapperHandler.cs - Parent page creation

...
/// <summary>
/// Creates a parent page for a given product content type and language
/// </summary>
/// <param name="displayName">The display name of the parent page</param>
/// <param name="languageName">The language in which to create the parent page</param>
/// <param name="contentTypeGuid">The GUID of the product content type</param>
/// <returns>The WebPageItemID of the created parent page</returns>
private int CreateParentPage(string displayName, string languageName, Guid contentTypeGuid)
{
    var parentData = new ContentItemData(new Dictionary<string, object>
    {
        { nameof(StoreSection.StoreSectionContentTypes), new List<Guid>(){ contentTypeGuid } },
    });

    var parentContentItemParameters = new ContentItemParameters(StoreSection.CONTENT_TYPE_NAME, parentData);

    var createParentPageParameters = new CreateWebPageParameters(displayName,
        languageName,
        parentContentItemParameters)
    {
        ParentWebPageItemID = GetStorePageId(languageName) ?? 0
    };

    createParentPageParameters.SetPageBuilderConfiguration(GetParentWidgetsConfiguration(), GetPageTemplateConfiguration());

    return webPageManager.Create(createParentPageParameters).GetAwaiter().GetResult();
}

/// <summary>
/// Creates a language variant for an existing parent page.
/// </summary>
/// <param name="displayName">The display name of the parent page</param>
/// <param name="languageName">The language in which to create the language variant</param>
/// <param name="contentTypeGuid">The GUID of the product content type</param>
/// <param name="webPageItemID">The WebPageItemID of the parent page</param>
private void CreateParentPageLanguageVariant(string displayName, string languageName, Guid contentTypeGuid, int webPageItemID)
{
    var parentData = new ContentItemData(new Dictionary<string, object>
    {
        { nameof(StoreSection.StoreSectionContentTypes), new List<Guid>(){ contentTypeGuid } },
    });
    var createLanguageVariantParameters =
        new CMS.Websites.CreateLanguageVariantParameters(webPageItemID,
                                                            languageName,
                                                            displayName,
                                                            parentData);

    createLanguageVariantParameters.SetPageBuilderConfiguration(GetParentWidgetsConfiguration(), GetPageTemplateConfiguration());

    if (!webPageManager.TryCreateLanguageVariant(createLanguageVariantParameters).GetAwaiter().GetResult())
    {
        logger.LogError(EventIds.ProductParentPageLanguageVariantCreateFailed,
            "Parent page language variant creation failed for product content type with GUID {ContentTypeGuid} in language {LanguageName}.",
            contentTypeGuid,
            languageName);
    }
}

/// <summary>
/// Retrieves the page ID of the store's main page
/// </summary>
/// <param name="languageName">The language to query</param>
/// <returns>The WebPageItemID of the store's main page, or null if not found</returns>
private int? GetStorePageId(string languageName) =>
    contentItemRetrieverService.RetrieveWebPageByPathWithoutContext<EmptyPage>(
            pathToMatch: STORE_PATH,
            includeSecuredItems: true,
            languageName: languageName,
            channelName: CHANNEL_NAME,
            forPreview: true)
        .GetAwaiter().GetResult()?.SystemFields.WebPageItemID;
...

Ensure a Store section exists for a type and language

Now let’s create a method called EnsureParentPageInLanguage to create parent pages (or language variants) if they do not already exist, and return their WebPageItemID. We can use this method to get a valid parent page ID when we create page wrappers for products.

Resolve content type metadata from contentTypeId, search existing parent sections under /Store through RetrieveWebPageChildrenByPathWithoutContext, and filter using the stored content type GUID in StoreSection.StoreSectionContentTypes. If no matching section exists, create one; if it exists but lacks the requested language variant, create that variant.

This gives product wrappers a stable, language-aware parent structure.

C#
ProductPageWrapperHandler.cs - Ensure parent page exists in language

...
/// <summary>
/// Ensures a parent page exists for the specified product content type in the current language
/// </summary>
/// <param name="contentTypeId">The ID of the product content type</param>
/// <param name="languageName">The language in which to ensure the parent page exists</param>
/// <param name="languageId">The ID of the language</param>
/// <returns>The WebPageItemID of the parent page</returns>
private int EnsureParentPageInLanguage(int contentTypeId, string languageName, int languageId)
{
    var contentType = DataClassInfoProvider.GetDataClassInfo(contentTypeId);
    var contentTypeGuid = contentType?.ClassGUID ?? Guid.Empty;

    // Get any language version of the parent page
    var existingParentPages = contentItemRetrieverService
        .RetrieveWebPageChildrenByPathWithoutContext(
            contentTypeNames: [StoreSection.CONTENT_TYPE_NAME],
            parentPagePath: STORE_PATH,
            customContentTypeQueryParameters: query => query,
            customContentQueryParameters: config => config
                // Filter store section pages that are assigned to the product's content type
                .Where(where => where.WhereContains(nameof(StoreSection.StoreSectionContentTypes), contentTypeGuid.ToString()))
                .TopN(1),
            forPreview: true,
            includeSecuredItems: true,
            depth: 0,
            languageName: null,
            channelName: CHANNEL_NAME)
        .GetAwaiter().GetResult();

    if (!existingParentPages.Any())
    {
        // If there is no parent page for the product type, create one and return its ID
        return CreateParentPage(
            displayName: contentType?.ClassDisplayName ?? "Store section",
            languageName: languageName,
            contentTypeGuid: contentTypeGuid
        );
    }
    else
    {
        // If there are no language variants in the specified language, create one
        if (!existingParentPages
            .Where(page => page.SystemFields.ContentItemCommonDataContentLanguageID == languageId)
            .Any())
        {
            CreateParentPageLanguageVariant(
                displayName: contentType?.ClassDisplayName ?? "Store section",
                languageName: languageName,
                contentTypeGuid: contentTypeGuid,
                webPageItemID: existingParentPages.First().SystemFields.WebPageItemID);
        }

        // Return the existing parent page ID
        return existingParentPages.First().SystemFields.WebPageItemID;
    }
}
...

Implement wrapper creation and language-variant creation

Now that we have a way to retrieve the parent pages, we can move on to write operations for product wrappers:

  • CreatePageWrapperForProduct for creating a new wrapper page
  • CreatePageWrapperLanguageVariantForProduct for creating a language variant of an existing wrapper page

In both cases, build ContentItemData that references the reusable product item’s GUID through ProductPage.ProductPageProducts and apply Page Builder configuration. For new pages, set ParentWebPageItemID using the EnsureParentPageInLanguage method, which we will add in the next step. Add failure logging for unsuccessful page or variant creation.

C#
ProductPageWrapperHandler.cs - Create wrapper page

...
/// <summary>
/// Creates a page wrapper for the specified product content item in the specified language, and returns the ID of the page. Ensures the parent page exists.
/// </summary>
/// <param name="displayName">The display name of the product content item, used for naming the page wrapper.</param>
/// <param name="languageName">The language for which to create the page wrapper.</param>
/// <param name="languageId">The ID of the language for which to create the page wrapper.</param>
/// <param name="contentTypeId">The content type ID of the product content item, used to find or create a parent page if necessary.</param>
/// <param name="contentItemGuid">The GUID of the product content item, used to link the page wrapper to the product.</param>
/// <returns>The ID of the created page.</returns>
/// </summary>
private int CreatePageWrapperForProduct(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid)
{
    var itemData = new ContentItemData(new Dictionary<string, object>
    {
        { nameof(ProductPage.ProductPageProducts), new List<ContentItemReference>()
            { new() { Identifier = contentItemGuid } } },
    });

    var contentItemParameters = new ContentItemParameters(ProductPage.CONTENT_TYPE_NAME, itemData);

    var createPageParameters = new CreateWebPageParameters(displayName, languageName, contentItemParameters)
    {
        ParentWebPageItemID = EnsureParentPageInLanguage(contentTypeId, languageName, languageId)
    };

    createPageParameters.SetPageBuilderConfiguration(GetProductWidgetsConfiguration(), GetPageTemplateConfiguration());
    int id = webPageManager.Create(createPageParameters).GetAwaiter().GetResult();

    if (id <= 0)
    {
        logger.LogError(EventIds.ProductWrapperCreateFailed,
            "Page wrapper creation failed for product content item with GUID {ContentItemGuid} in language {LanguageName}.",
            contentItemGuid,
            languageName);
    }

    return id;
}
...
/// <summary>
/// Creates a language variant of a product's page wrapper in the specified language
/// </summary>
/// <param name="displayName">The display name of the content item to apply to the page wrapper</param>
/// <param name="languageName">The language in which to create the variant</param>
/// <param name="contentItemGuid">The GUID of the content item to be referenced by the page wrapper</param>
/// <param name="existingPageId">The WebPageItemID of the existing page</param>
private void CreatePageWrapperLanguageVariantForProduct(string displayName, string languageName, Guid contentItemGuid, int existingPageId)
{
    var itemData = new ContentItemData(new Dictionary<string, object>
    {
        { nameof(ProductPage.ProductPageProducts), new List<ContentItemReference>()
            { new() { Identifier = contentItemGuid } } },
    });

    var createLanguageVariantParameters =
        new CMS.Websites.CreateLanguageVariantParameters(existingPageId,
                                                            languageName,
                                                            displayName,
                                                            itemData);

    createLanguageVariantParameters.SetPageBuilderConfiguration(GetProductWidgetsConfiguration(), GetPageTemplateConfiguration());

    if (!webPageManager.TryCreateLanguageVariant(createLanguageVariantParameters).GetAwaiter().GetResult())
    {
        logger.LogError(EventIds.ProductWrapperLanguageVariantCreateFailed,
            "Page wrapper language variant creation failed for product content item with GUID {ContentItemGuid} in language {LanguageName}.",
            contentItemGuid,
            languageName);
    }
}
...

Look up page wrappers and ensure their existence

Add a new method called GetProductPagesForProduct to find existing wrapper pages referencing a product item’s GUID.

Try to find existing pages that exist somewhere in the /Store section. Filter by linked product GUID using WhereContains rather than .Linking, so that the method will still work during deletion events. If a language ID is provided, use WhereEquals to filter the language to avoid language fallbacks, guaranteeing that the query only returns a page if it exists in the provided language.

Then, add an EnsureProductPageWrapperForLanguage method to guarantee wrapper presence in a given target language.

It should return existing wrappers in the requested language if they exist. If not, it should either create a new page wrapper or language variant, then return the newly-created wrapper.

C#
ProductPageWrapperHandler - Ensure wrapper exists for language

...
/// <summary>
/// Retrieves all product pages that reference the specified product.
/// </summary>
/// <param name="guid">GUID of the product item being referenced</param>
/// <param name="languageName">Language version to check</param>
/// <param name="languageId">Language ID to check (must match the language name)</param>
/// <returns>A collection of product pages that reference the specified product</returns>
private IEnumerable<IWebPageFieldsSource> GetProductPagesForProduct(Guid guid, string? languageName, int? languageId)
{
    Func<ContentQueryParameters, ContentQueryParameters> customContentQueryParameters;

    Func<ContentQueryParameters, ContentQueryParameters> whereFilter = config => config
        // Normally we would use the .Linking(...) method in the customContentTypeQueryParameters function.
        // However, during the delete event, the linking data is already removed,
        // so we must manually check for the GUID in ProductPageProducts.
        .Where(where => where.WhereContains(nameof(ProductPage.ProductPageProducts), guid.ToString()));

    customContentQueryParameters = languageId is int intLanguageId
        ? (config => whereFilter(config
            // Filter by language ID to ensure we only get pages in the specific language, without falling back to other languages
            .Where(where => where.WhereEquals(
                nameof(ProductPage.SystemFields.ContentItemCommonDataContentLanguageID), intLanguageId))))
        : whereFilter;

    return contentItemRetrieverService
            .RetrieveWebPageChildrenByPathWithoutContext(
                contentTypeNames: [ProductPage.CONTENT_TYPE_NAME],
                parentPagePath: STORE_PATH,
                customContentTypeQueryParameters: query => query,
                customContentQueryParameters: customContentQueryParameters,
                forPreview: true,
                includeSecuredItems: true,
                depth: 0,
                languageName: languageName,
                channelName: CHANNEL_NAME)
            .GetAwaiter().GetResult();
}

/// <summary>
/// Ensures that a product page exists for the specified product and language. If it doesn't exist, it will be created. If a page exists in another language, a language variant will be created for the current language.
/// </summary>
/// <param name="displayName">Display name for new variant if creation required</param>
/// <param name="languageName">Language name of variant to retrieve</param>
/// <param name="languageId">Language ID of variant to retrieve (must match language name)</param>
/// <param name="contentTypeId">ID of the reusable product item's content type</param>
/// <param name="contentItemGuid">GUID of the reusable content item</param>
/// <returns>An enumerable collection of wrapper pages linking the specified product</returns>
private IEnumerable<IWebPageFieldsSource> EnsureProductPageWrapperForLanguage(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid)
{
    var langSpecificProductPages = GetProductPagesForProduct(contentItemGuid, languageName, languageId);

    if (langSpecificProductPages.Any())
    {
        return langSpecificProductPages;
    }

    var allLanguageProductPages = GetProductPagesForProduct(contentItemGuid, null, null);

    if (allLanguageProductPages.Any())
    {
        var uniquePageIDs = allLanguageProductPages.Select(page => page.SystemFields.WebPageItemID).Distinct();

        foreach (int pageId in uniquePageIDs)
        {
            // We should still make sure the parent exists in the current language, but we don't need its ID to create a new page
            _ = EnsureParentPageInLanguage(contentTypeId, languageName, languageId);

            CreatePageWrapperLanguageVariantForProduct(displayName, languageName, contentItemGuid, pageId);
        }

        return GetProductPagesForProduct(contentItemGuid, languageName, languageId);
    }

    _ = CreatePageWrapperForProduct(displayName, languageName, languageId, contentTypeId, contentItemGuid);

    return GetProductPagesForProduct(contentItemGuid, languageName, languageId);
}
...

Implement event handlers and map each event to wrapper behavior

With our page orchestration logic in place, we can finally implement the event handlers. Each time a reusable product item is created, translated, published, unpublished, or deleted, the same should happen to its corresponding page wrapper in the given language.

Each handler should start with IsApplicableType and abort execution for non-product content types.

C#
ProductPageWrapperHandler.cs - Create event handler

...
// Handler for content item creation
private void ContentItem_Create_After(object sender, CreateContentItemEventArgs e)
{
    if (e.ID is null || !IsApplicableType(e.ContentTypeName))
        return;

    // Create a web page wrapper for the newly created product
    if (e.GUID is not null)
    {
        _ = CreatePageWrapperForProduct(e.DisplayName, e.ContentLanguageName, e.ContentLanguageID, e.ContentTypeID, (Guid)e.GUID);
    }
}

// Handler for content item language variant creation
private void ContentItem_CreateLanguageVariant_After(object sender, CreateContentItemLanguageVariantEventArgs e)
{
    if (!IsApplicableType(e.ContentTypeName))
        return;

    _ = EnsureProductPageWrapperForLanguage(e.DisplayName, e.ContentLanguageName, e.ContentLanguageID, e.ContentTypeID, e.Guid);
}

// Handler for content item deletion
private void ContentItem_Delete_Execute(object sender, DeleteContentItemEventArgs e)
{
    if (!IsApplicableType(e.ContentTypeName))
        return;

    // If there is no existing page in the language being deleted, we don't need to create a new one, so we will not use the ensure method.
    var productPages = GetProductPagesForProduct(e.Guid, e.ContentLanguageName, e.ContentLanguageID);

    foreach (var productPage in productPages)
    {
        webPageManager.Delete(
                new DeleteWebPageParameters(productPage.SystemFields.WebPageItemID, e.ContentLanguageName)
                {
                    Permanently = true,
                }).GetAwaiter().GetResult();
    }
}

// Handler for content item publishing
private void ContentItem_Publish_Execute(object sender, PublishContentItemEventArgs e)
{
    if (!IsApplicableType(e.ContentTypeName))
        return;

    var productPages = EnsureProductPageWrapperForLanguage(e.DisplayName, e.ContentLanguageName, e.ContentLanguageID, e.ContentTypeID, e.Guid);

    foreach (var productPage in productPages)
    {
        if (!webPageManager.TryPublish(productPage.SystemFields.WebPageItemID, e.ContentLanguageName).GetAwaiter().GetResult())
        {
            logger.LogError(EventIds.ProductWrapperPublishFailed,
            "Publish failed for product page with ID {WebPageItemID} for product content item ID {ContentItemID} in language {LanguageName}.",
            productPage.SystemFields.WebPageItemID,
            e.ID,
            e.ContentLanguageName);
        }
    }
}

// Handler for content item unpublishing
private void ContentItem_Unpublish_Execute(object sender, UnpublishContentItemEventArgs e)
{
    if (!IsApplicableType(e.ContentTypeName))
        return;

    var productPages = GetProductPagesForProduct(e.Guid, e.ContentLanguageName, e.ContentLanguageID);

    foreach (var productPage in productPages)
    {
        if (!webPageManager.TryUnpublish(productPage.SystemFields.WebPageItemID, e.ContentLanguageName).GetAwaiter().GetResult())
        {
            logger.LogError(EventIds.ProductWrapperUnpublishFailed,
            "Unpublish failed for product page with ID {WebPageItemID} for product content item ID {ContentItemID} in language {LanguageName}.",
            productPage.SystemFields.WebPageItemID,
            e.ID,
            e.ContentLanguageName);
        }
    }
}
...

Add logging event IDs

Finally, add EventId fields in your shared EventIds class for the new failure logs used by the handler.

At minimum, include IDs for product wrapper create failure, wrapper language-variant create failure, wrapper publish failure, wrapper unpublish failure, and parent-page language-variant create failure.

Structured event IDs make diagnostics and monitoring much easier over time.

C#
~/Features/Shared/Logging/EventIds.cs

internal static class EventIds
{
    ...
    public static readonly EventId ProductWrapperCreateFailed = new(1013, nameof(ProductWrapperCreateFailed));
    public static readonly EventId ProductWrapperLanguageVariantCreateFailed = new(1014, nameof(ProductWrapperLanguageVariantCreateFailed));
    public static readonly EventId ProductWrapperPublishFailed = new(1015, nameof(ProductWrapperPublishFailed));
    public static readonly EventId ProductWrapperUnpublishFailed = new(1016, nameof(ProductWrapperUnpublishFailed));
    public static readonly EventId ProductParentPageLanguageVariantCreateFailed = new(1017, nameof(ProductParentPageLanguageVariantCreateFailed));
}

Check your progress

Now you can see the handler in action, reacting to product content item creation, translation, and (un)publishing actions to update pages in the website channel:

You can also try creating new product content types to test the parent page creation functionality. Just remember to generate code files for the content type before testing, as the GetApplicableTypeNames method relies on the classes and interfaces in code.

Troubleshooting

If wrapper pages are not being created or updated as expected, start with these checks.

Event handlers are not firing

Confirm the module is registered with [assembly: RegisterModule(typeof(ProductPageWrapperHandler))], verify that OnInit executes and subscribes to events, and make sure the incoming content item type actually passes IsApplicableType.

If your environment prevents you from debugging with breakpoints, you can place a log entry at the start of each event handler to verify execution order and event payload values.

No parent page found or created

Verify that a page with the tree path /Store exists in the target channel and language (or a fallback language), that CHANNEL_NAME and WEB_CHANNEL_GUID point to the intended website channel, and that RetrieveWebPageByPathWithoutContext<EmptyPage> is called with the expected languageName and forPreview values.

Wrapper pages are created in the wrong place

Check the return value of EnsureParentPageInLanguage, confirm StoreSection.StoreSectionContentTypes contains the expected product content type GUID, and verify the parent lookup uses WhereContains(nameof(StoreSection.StoreSectionContentTypes), contentTypeGuid.ToString()).

Language variant is missing

Verify the event payload provides the expected ContentLanguageName and ContentLanguageID, confirm EnsureProductPageWrapperForLanguage is called during create-language-variant and publish events, and check whether CreateParentPageLanguageVariant is needed first (a missing parent variant blocks child placement).

Publish or unpublish does not match product state

Confirm wrapper lookup returns pages for the same product GUID and language, verify publish/unpublish loops call TryPublish and TryUnpublish with the event language, and check logs for ProductWrapperPublishFailed and ProductWrapperUnpublishFailed event IDs.

What’s next?

Keep an eye out for new upcoming training guides about Commerce!

These resources may also help: