<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><language>en</language><title>Blog posts by Tomek Juranek</title> <link>https://world.optimizely.com/blogs/tomek-juranek/</link><description></description><ttl>60</ttl><generator>Optimizely World</generator><item> <title>Branch Templates in Optimizely CMS</title>            <link>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/6/branch-page-templates-in-optimizely-cms/</link>            <description>&lt;p&gt;Optimizely CMS natively doesn&#39;t support branch templates, a concept known from different content management systems. Branch templates are useful if we want to help content authors to automate repetetive tasks with multiple content items, for example creation of a new product pages with subpages, all with predefined layout containing various blocks. This issue can be solved by copying and pasting exisitng page and adjusting the content on the copy, but it&#39;s more like a workaround which solves the problem only partially. For the blocks initial values in Optimizely CMS we can use &lt;code style=&quot;display: inline;&quot;&gt;SetDefaultValues&lt;/code&gt; method but managing it from code for larger structures can be painful, also it doesn&#39;t solve the problem of automated subpages creation. We can solve both issues by adding branch template functionality to the CMS.&lt;/p&gt;
&lt;p&gt;We can achieve it by adding simple code, first let&#39;s add a new item template, we will only use it as a folder, so we don&#39;t need any fields:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ContentType(DisplayName = &quot;White Label Template Folder&quot;,
        GUID = &quot;2acce58b-41cc-4edc-a361-a1baa86b51bc&quot;,
        Description = &quot;A folder which allows to structure new branch templates.&quot;,
        GroupName = TabNames.BranchTemplate, Order = 40)]
public class TemplateFolderPage : PageData
{
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can also configure the folder icon:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[UIDescriptorRegistration]
public class TemplateFolderPageUIDescriptor : UIDescriptor&amp;lt;TemplateFolderPage&amp;gt;
{
    public TemplateFolderPageUIDescriptor()
            : base(ContentTypeCssClassNames.Folder)
    {
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To implement the core functionality we can use the code below. In a nutshell, inside the &lt;code style=&quot;display: inline;&quot;&gt;CreatedContent&lt;/code&gt; event, we first do some pre-checks: &lt;br /&gt;- Is it a &quot;new item&quot; event? (we don&#39;t want to execute the code for example on copy/paste opetation).&amp;nbsp;&lt;br /&gt;- Do we have a folder with our branch templates under the root?&lt;br /&gt;- Do we have a page item with the currently created type inside the root branch templates folder?&amp;nbsp;&lt;br /&gt;If all checks passed, we create a deep copy of the item from the branch folder and use it to replace the original page. We make sure that page name and URLSegment are unique:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[InitializableModule]
[ModuleDependency(typeof(InitializationModule))]
public class BranchTemplateInitialization : IInitializableModule
{
    private IContentRepository _contentRepository;
    private IContentLoader _contentLoader;
    private UrlSegmentOptions _urlSegmentOptions;
    private IUniqueIdentityCreator _uniqueIdentityCreator;

    public void Initialize(InitializationEngine context)
    {
        var contentEvents = ServiceLocator.Current.GetInstance&amp;lt;IContentEvents&amp;gt;();
        _contentRepository ??= ServiceLocator.Current.GetInstance&amp;lt;IContentRepository&amp;gt;();
        _contentLoader ??= ServiceLocator.Current.GetInstance&amp;lt;IContentLoader&amp;gt;();
        _urlSegmentOptions ??= ServiceLocator.Current.GetInstance&amp;lt;UrlSegmentOptions&amp;gt;();
        _uniqueIdentityCreator ??= ServiceLocator.Current.GetInstance&amp;lt;IUniqueIdentityCreator&amp;gt;();

        contentEvents.CreatedContent += ContentEvents_CreatedContent;
    }

    public void Uninitialize(InitializationEngine context)
    {
        var contentEvents = ServiceLocator.Current.GetInstance&amp;lt;IContentEvents&amp;gt;();
        contentEvents.CreatedContent -= ContentEvents_CreatedContent;
    }

    private void ContentEvents_CreatedContent(object sender, ContentEventArgs e)
    {
        var saveArgs = e as SaveContentEventArgs;
        if (e.Content == null || (saveArgs != null &amp;amp;&amp;amp; saveArgs.MaskedAction == SaveAction.CheckOut))
        {
            // skip other actions like copying
            return;
        }

        var templateFolder = _contentLoader.GetChildren&amp;lt;TemplateFolderPage&amp;gt;(ContentReference.RootPage).FirstOrDefault();
        if (templateFolder == null) 
        { 
            return; 
        }

        // this is simplified code which only take 1st branch template item with the given type. 
        var myTemplateItem = _contentLoader.GetChildren&amp;lt;IContent&amp;gt;(templateFolder.ContentLink)?.FirstOrDefault(x =&amp;gt; x.ContentTypeID == e.Content.ContentTypeID);
        if (myTemplateItem == null) 
        {
            return; 
        }

        var name = e.Content.Name;
        var oldItemLink = e.ContentLink;
        var parentLink = e.Content.ParentLink;
        var newItemLink = _contentRepository.Copy(myTemplateItem.ContentLink, parentLink, AccessLevel.NoAccess, AccessLevel.NoAccess, false);
        var newItem = _contentRepository.Get&amp;lt;PageData&amp;gt;(newItemLink);
        _contentRepository.Delete(oldItemLink, true);

        // rename page name and url segment to the one entered by author
        var newPage = newItem.CreateWritableClone();
        // we need to rename to new name and before forcing uniqueness
        newPage.Name = name;
        newPage.URLSegment = name;
        newPage.Name = _uniqueIdentityCreator.CreateName(newPage, name);
        newPage.URLSegment = _uniqueIdentityCreator.CreateURLSegment(newPage, _urlSegmentOptions);
        _contentRepository.Save(newPage, SaveAction.Default, AccessLevel.NoAccess);

        // update content link to point to new item
        e.ContentLink = newItemLink;
        e.Content = newItem;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To make it work, we need to create the folder of type &lt;code style=&quot;display: inline;&quot;&gt;TemplateFolderPage&lt;/code&gt; under the root (I called it &quot;Products Wizard&quot;), then inside that folder we create a new page of selected type and predefined layout (I called it &quot;Simple Product Template&quot; and used &quot;Product Page&quot; type, but this is just an example). I also added a sub page called &quot;Gallery&quot; with it&#39;s own layout. This structure will serve as a template for all new product pages:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/7e6fb8b21b46487794adf88c90529976.aspx?1753124214134&quot; width=&quot;1125&quot; height=&quot;768&quot; /&gt;&lt;/p&gt;
&lt;p&gt;With the code in place, we can now create w new &quot;Product Page&quot; item using the standard &quot;New Page&quot; dialog. It should create a new product page under the given name and url segment, but with the predefined layout and the gallery subpage inside.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/6/branch-page-templates-in-optimizely-cms/</guid>            <pubDate>Tue, 22 Jul 2025 18:29:24 GMT</pubDate>           <category>Blog post</category></item><item> <title>Display page/block thumbnail based on selected site in multi-site solution</title>            <link>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/5/display-pageblock-thumbnail-based-on-selected-site-in-multi-site-solution/</link>            <description>&lt;p&gt;In &lt;a href=&quot;/link/3328a42942574e7181ebf1b90dc3c348.aspx&quot;&gt;previous blog&lt;/a&gt; we described how to control the visibility of the blocks or properties based on the current site in multisite solution. We can use the same technique to display different thumbnails for pages and blocks inside the CMS based on selected site, to help the content authors to identify the components or page types.&lt;/p&gt;
&lt;p&gt;Assuming we have &lt;code style=&quot;display: inline;&quot;&gt;SiteHelper&lt;/code&gt; helper class from the &lt;a href=&quot;/link/3328a42942574e7181ebf1b90dc3c348.aspx&quot;&gt;previous blog&lt;/a&gt;, we can create a folder structure under wwwroot, where each site has its own subfolder with thumbnail images and the subfolder names match our&lt;code style=&quot;display: inline;&quot;&gt;MySite&lt;/code&gt; enum values:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/9ec05f14d0614e60a62fe1b3a4d6bc50.aspx&quot; alt=&quot;&quot; width=&quot;400&quot; height=&quot;421&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Now we can implement an Attribute for our Blocks and Pages inheriting from &lt;code style=&quot;display: inline;&quot;&gt;ImageUrlAttribute&lt;/code&gt;. In constructor we will list the sites for which the thumbnail is available:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [AttributeUsage(AttributeTargets.Class)]
    public class ImageUrlSitesAttribute : ImageUrlAttribute
    {
        private readonly ISiteHelper _siteHelper;

        //root folder with our thumbnails
        private readonly string _basePath = &quot;/icons/blocks/&quot;;

        public Dictionary&amp;lt;MySite, string&amp;gt; Paths { get; set; }

        public override string Path
        {
            get
            {
                var currentSite = _siteHelper.GetCurrentSite(null);
                if (Paths.ContainsKey(currentSite))
                {
                    var currentPath = Paths[currentSite];
                    if (!string.IsNullOrWhiteSpace(currentPath))
                    {
                        return currentPath;
                    }
                }

                //default if not defined for site, we can place generic images in the _basePath folder
                return _basePath + base.Path;
            }
        }

        public ImageUrlSitesAttribute(string path, params MySite[] sites) : base(path)
        {
            _siteHelper = ServiceLocator.Current.GetService&amp;lt;ISiteHelper&amp;gt;();
            Paths = new Dictionary&amp;lt;MySite, string&amp;gt;();
            foreach (var site in sites)
            {
                var sitePath = System.IO.Path.Combine(_basePath, site.ToString().ToLower(), path);
                Paths.Add(site, sitePath);
            }
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can use now the attribute in Page class:&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [ContentType(
        DisplayName = &quot;Generic Page&quot;,
        Description = &quot;Standard page of an article.&quot;,
        GUID = &quot;0AF06AF4-3185-4D8C-A65E-D942AC9A27D3&quot;)]
    [ImageUrlBrands(&quot;GenericPage.jpg&quot;, sites: new[] { MySite.Site1, MySite.Site2, MySite.Site3 })]
    public class GenericPage : PageData
    {
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or Block class:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [ContentType(DisplayName = &quot;Hero Full Screen&quot;,
        GUID = &quot;A0C85434-35DE-42BF-88B7-1136B936AEAD&quot;,
        Description = &quot;Display an immersive hero when the brand and/or hotel website pages load.&quot;,
        GroupName = GroupNames.Hero)]
    [ImageUrlBrands(&quot;HeroFullScreen.jpg&quot;, sites: new [] { MySite.Site1, MySite.Site2, MySite.Site3, MySite.Site4})]
    public class HeroFullScreenBlock : BlockData
    {
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Final results, when authors add new block under Site1:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/b4de0c16048243c6ab326da05c6c18b1.aspx&quot; alt=&quot;&quot; width=&quot;800&quot; height=&quot;467&quot; /&gt;&lt;/p&gt;
&lt;p&gt;and under Site2:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/e289696068a542b290f6fc969733d9d2.aspx&quot; alt=&quot;&quot; width=&quot;800&quot; height=&quot;421&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/5/display-pageblock-thumbnail-based-on-selected-site-in-multi-site-solution/</guid>            <pubDate>Fri, 16 May 2025 11:50:18 GMT</pubDate>           <category>Blog post</category></item><item> <title>Show/hide blocks and properties based on selected site in multi-site solution</title>            <link>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/4/block-thumbnail-based-on-website-on-multisite-solution/</link>            <description>&lt;p&gt;Optimizely PaaS CMS has a great support for multi-site solutions which can additionally be extended with the custom code. For example, we can enhance it to show or hide entire blocks or blocks elements based on the selected site in a multi-site setup. This enables tailored content experiences across different domains while maintaining a single content structure.&lt;/p&gt;
&lt;p&gt;First let&#39;s create enum with list of our sites, it should match site definition names in CMS Settings -&amp;gt; Manage Websites panel:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public enum MySite 
{
    Site1,
    Site2,
    Site3
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We will also need a helper class to resolve a site based on current host name and port (helpful for local development):&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public interface ISiteHelper
{
    public MySite GetCurrentSite(ExtendedMetadata metadata);
}

public class SiteHelper : ISiteHelper
{
    private readonly ISiteDefinitionResolver _siteDefinitionResolver;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public SiteHelper(ISiteDefinitionResolver siteDefinitionResolver, IHttpContextAccessor httpContextAccessor)
    {
        _siteDefinitionResolver = siteDefinitionResolver;
        _httpContextAccessor = httpContextAccessor;
    }

    public MySite GetCurrentSite(ExtendedMetadata metadata)
    {
        dynamic contentMetadata = metadata;
        var ownerContent = contentMetadata?.OwnerContent as IContent;

        SiteDefinition siteDefinition = null;
        if (ownerContent != null &amp;amp;&amp;amp; ownerContent?.ContentLink?.ID != 0)
        {
            siteDefinition = _siteDefinitionResolver.GetByContent(ownerContent?.ContentLink, true);
        }

        if (siteDefinition == null){
            var request = _httpContextAccessor.HttpContext?.Request;
            var host = request?.Host.Host;
            if (request.Host.Port != null)
            {
                host += &quot;:&quot; + request.Host.Port;
            }
            siteDefinition = _siteDefinitionResolver.GetByHostname(host, true);
        }

        if (siteDefinition != null)
        {
            foreach (var name in Enum.GetNames(typeof(MySite)))
           {
               if (siteDefinition.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))
               {
                   return (MySite)Enum.Parse(typeof(MySite), name);
               }
           }
        }
        throw new Exception($&quot;Site not configured&quot;);
    }
}
 &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next we can implement an attribute which utilizes the helper class described above. Besides hiding the element, we want to ensure that if property is required but should be hidden validation logic won&#39;t be execured for that site:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ShowOnSitesAttribute : Attribute, IDisplayMetadataProvider
{
    private readonly MySite[] _sites;
    private readonly ISiteHelper _siteHelper;
    public bool Hide =&amp;gt; !_sites.Contains(_siteHelper.GetCurrentSite());
    
    public ShowOnSitesAttribute(params MySite[] sites)
    {
        _sites = sites;
        _siteHelper = ServiceLocator.Current.GetService();
    }

    public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
    {
         var extendedMetadata = context.DisplayMetadata.AdditionalValues[ExtendedMetadata.ExtendedMetadataDisplayKey] as ExtendedMetadata;
         if (_sites.Contains(_siteHelper.GetCurrentSite(extendedMetadata)))
        {
            return;
        }

        if (extendedMetadata == null)
        {
            return;
        }

        extendedMetadata.ShowForEdit = false;
        foreach (var property in extendedMetadata.Properties)
        {
            property.IsRequired = false;
            property.ValidationMetadata.IsRequired = false;
        }
    }
} &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally we can use the attribute in a block for entire class or for the properties:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ContentType(DisplayName = &quot;My Block&quot;, GUID = &quot;A4ED27A1-2B34-4323-885E-4B478A137578&quot;)]
[ShowOnSites(MySite.Site1, MySite.Site2)]
public class MyBlock : BlockData
{
    [CultureSpecific]
    [Required]
    [Display(Name = &quot;Heading&quot;, GroupName = TabNames.Content, Order = 10)]
    [ShowOnSites(MySite.Site1)]
    public virtual string Heading { get; set; }

    [CultureSpecific]
    [Display(Name = &quot;Description&quot;, GroupName = TabNames.Content, Order = 20)]
    public virtual XhtmlString Description { get; set; }
} &lt;/code&gt;&lt;/pre&gt;</description>            <guid>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/4/block-thumbnail-based-on-website-on-multisite-solution/</guid>            <pubDate>Sun, 06 Apr 2025 10:45:04 GMT</pubDate>           <category>Blog post</category></item><item> <title>Revalidate Page in Next.js after Publishing Content in Headless Optimizely PaaS CMS.</title>            <link>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/3/revalidate-page-in-next.js-after-content-publish-in-optimizely-cms-paas</link>            <description>&lt;p&gt;Headless CMS implementations are becoming increasingly popular. In this approach, the CMS and the front-end application are decoupled and can use different technology stacks. However, when building such a website, it is essential to ensure a seamless connection between the CMS and the front-end to maintain a smooth editing experience.&lt;/p&gt;
&lt;p&gt;One of the most popular framework to implement head application for the headless CMS is Next.js, which offers different rendering techniques: client or server side rendering, static site generation and incremental static regeneration. We will not cover all of them here, if you need more information check out&amp;nbsp;&lt;a href=&quot;/link/f90cbb7fda164eeb87097c16adaeb346.aspx&quot;&gt;this article&lt;/a&gt;. In this blog we will focus on Incremental Static Regeneration (ISR), which is the way to go for majority of cases when working with CMS. ISR combines the ability of pre-rendering content to HTML files from Static Site Generation and flexibility of regenerating single pages without the need of rebuilding entire front-end application each time new content is published.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;There are two ways of implementing the app in Next.js: &lt;a href=&quot;https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration&quot;&gt;Page Router&lt;/a&gt; and a newer&amp;nbsp;&lt;a href=&quot;https://nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration&quot;&gt;App Router&lt;/a&gt;, in both, ISR works similar with some differences in Next APIs. In the base implementation content cache is invalidated in given period of time for example every 60 seconds. For the CMS editors this approach may be annoying cause they may not see their changes immediately after the publish. To address this issue we can implement on-demand revalidation and execute it every time content is published in Optmizely CMS. Keep in mind that revalidation in Next.js invalidates the cache, the actual regeneration happens when page is accessed for the first time.&lt;/p&gt;
&lt;p&gt;First we need to implement a Content Published event. For Optimizely PaaS CMS we can use following code which hooks into the event, then finds the pages which content did changed and calls Next.js API passing security secret and the paths to changed pages:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-C# hljs&quot;&gt;

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class EventsInitialization : IInitializableModule
{
    private IContentEvents _contentEvents;
    private IContentRepository _contentRepository;
    private IContentLoader _contentLoader;
    private ISiteDefinitionResolver _sideDefinitionResolver;
    private IConfiguration _configuration;
    private IHttpClientFactory _httpClientFactory;

    public void Initialize(InitializationEngine context)
    {
        _contentEvents ??= ServiceLocator.Current.GetInstance();
        _contentRepository ??= ServiceLocator.Current.GetInstance();
        _contentLoader ??= ServiceLocator.Current.GetInstance();
        _sideDefinitionResolver ??= ServiceLocator.Current.GetInstance();
        _configuration ??= ServiceLocator.Current.GetInstance();
        _httpClientFactory ??= ServiceLocator.Current.GetService();
        _contentEvents.PublishedContent += ContentEvents_PublishedContent;
    }

    public void Uninitialize(InitializationEngine context)
    {
        _contentEvents ??= ServiceLocator.Current.GetInstance();
        _contentEvents.PublishedContent -= ContentEvents_PublishedContent;
    }

    private void ContentEvents_PublishedContent(object sender, ContentEventArgs e)
    {
        if (sender == null || e == null) return;

        var pages = PagesToRevalidate(e.Content);
        foreach (var page in pages)
        {
            RevalidatePages(page);
        }
    }

    protected List&amp;lt;BasePageData&amp;gt; PagesToRevalidate(IContent content)
    {
        // TODO this is simplified method to find pages referenced by the modified content. There may be more cases like deleting page, change page url and updating references, updating settings items, etc.
        if (content is BasePageData page)
        {
            return new List&amp;lt;BasePageData&amp;gt; { page };
        }

        return GetRefPages(content);
    }

    protected List&amp;lt;BasePageData&amp;gt; GetRefPages(IContent content)
    {
        var pages = new List&amp;lt;BasePageData&amp;gt;();
        if (content is BaseBlockData)
        {
            var references = _contentRepository.GetReferencesToContent(content.ContentLink, true).ToList();
            foreach (var reference in references)
            {
                var refContent = _contentLoader.Get(reference.OwnerID);
                if (refContent != null &amp;amp;&amp;amp; refContent is BasePageData pageRef)
                {
                    if (!pages.Any(p =&amp;gt; p.ContentLink.ID == refContent.ContentLink.ID))
                    {
                        pages.Add(pageRef);
                    }
                }
                else if (refContent is BaseBlockData)
                {
                    var refPages = GetRefPages(refContent);
                    foreach (var page in refPages)
                    {
                        if (!pages.Any(p =&amp;gt; p.ContentLink.ID == page.ContentLink.ID))
                        {
                            pages.Add(page);
                        }
                    }
                }
            }
        }
        return pages;
    }

    protected void RevalidatePages(BasePageData page)
    {
        var langUrls = new List&amp;lt;string&amp;gt;();
        //TODO We take all languages, assuming pages have language fallback and changed content may be culture invariant. 
        foreach(var language in page.ExistingLanguages)
        {
            var url = UrlResolver.Current.GetUrl(page.ContentLink, language.Name);
            if (url != null)
            {
                langUrls.Add(url);
            }
        }

        var site = _sideDefinitionResolver.GetByContent(page.ContentLink, true);
        if (site == null || langUrls.Count == 0)
        {
            return;
        }

        var secretKey = _configuration[&quot;NextRevalidate:Secret&quot;];
        var frontUrl = $&quot;https://my-front-end-app.com/api/revalidate?secret={secretKey}&quot;;
        GetWebResponseAsync(frontUrl, langUrls);
    }

    protected async void GetWebResponseAsync(string apiUrl, List&amp;lt;string&amp;gt; urls)
    {
        HttpClient httpClient = _httpClientFactory.CreateClient();
        HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, apiUrl);
        httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(&quot;application/json&quot;));
        httpRequestMessage.Content = new StringContent(JsonConvert.SerializeObject(urls), Encoding.UTF8, &quot;application/json&quot;);

        using (var reader = new StreamReader(await (await httpClient.SendAsync(httpRequestMessage).ConfigureAwait(continueOnCapturedContext: false)).Content.ReadAsStreamAsync(), Encoding.UTF8))
        {
            var resultText = await reader.ReadToEndAsync();
            Log.Information(&quot;Revalidate API result:&quot;, resultText);
        }
    }
}

&lt;/code&gt;
&lt;/pre&gt;
&lt;p&gt;Now we can implement &quot;revalidate&quot; API route in our Next.js application. This example uses Page Router, for the App Router adjust API function and use&amp;nbsp;&lt;code class=&quot;code-block_code__isn_V&quot;&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color: var(--shiki-token-function);&quot;&gt;revalidatePath&lt;/span&gt;&lt;span style=&quot;color: var(--shiki-color-text);&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: var(--shiki-token-string-expression);&quot;&gt;&#39;/path&#39;&lt;/span&gt;&lt;span style=&quot;color: var(--shiki-color-text);&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt; instead of &lt;code class=&quot;code-block_code__isn_V&quot;&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color: var(--shiki-token-function);&quot;&gt;res.revalidate&lt;/span&gt;&lt;span style=&quot;color: var(--shiki-color-text);&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color: var(--shiki-token-string-expression);&quot;&gt;&#39;/path&#39;&lt;/span&gt;&lt;span style=&quot;color: var(--shiki-color-text);&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt; The implementation reads from query parameters the secret (and compare it with value from env variable) and the path to revalidate ISR.&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript hljs&quot;&gt;
import type { NextApiRequest, NextApiResponse } from &#39;next&#39;;
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) { 
  try {
    if (req.query.secret !== process.env.NEXTAUTH_SECRET) {
        return res.status(401).json({ message: &#39;Invalid token&#39; });
    }

    if (!req.body){
      return;
    }

    var results: string[] = await Promise.all(req.body.map(async (url: string): Promise =&amp;gt; {
      await res.revalidate(url);
      return url;
    }));

    return res.json({ revalidated: results });
  } catch (err) {
    console.error(err);
    return res.status(500).send(&#39;Error revalidating&#39;);
  }
}
&lt;/code&gt;
&lt;/pre&gt;
&lt;p&gt;Run your Next application with &lt;code&gt;next build&lt;/code&gt; and &lt;code&gt;next start&lt;/code&gt; commands and start your CMS and publish some content, the change should be visible immediately after refreshing the page.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/3/revalidate-page-in-next.js-after-content-publish-in-optimizely-cms-paas</guid>            <pubDate>Tue, 25 Mar 2025 10:24:36 GMT</pubDate>           <category>Blog post</category></item><item> <title>Optimizely CMS Developer Tools for macOS</title>            <link>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/3/optimizely-developer-tools-for-macos/</link>            <description>&lt;p&gt;Running &lt;strong&gt;Optimizely CMS&lt;/strong&gt; on &lt;strong&gt;macOS&lt;/strong&gt; presents unique challenges, as the platform was traditionally primarily designed for Windows environments. However, with the right setup-leveraging tools like Docker, .NET Framework, VS Code, Azure Storage Explorer and Azure Data Studio developers can create a smooth and efficient workflow for building and testing Optimizely projects on macOS. This article explores the necessary configurations, step-by-step installation processes to ensure a seamless development experience.&lt;/p&gt;
&lt;h2&gt;Installation&lt;/h2&gt;
&lt;p&gt;First of all you need to install &lt;a href=&quot;https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-macos?view=powershell-7.5&quot;&gt;Powershell&lt;/a&gt; on your Mac, there are couple of different methods to install it as described &lt;a href=&quot;https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-macos&quot;&gt;here&lt;/a&gt;. After the installation run &lt;code&gt;pwsh&lt;/code&gt; in macOS terminal. Majority of CLI commands in this article should be run from Powershell.&lt;/p&gt;
&lt;p&gt;To host PaaS CMS database on macOS you need&amp;nbsp;&lt;a href=&quot;https://docs.docker.com/desktop/setup/install/mac-install/&quot;&gt;Docker Desktop&lt;/a&gt;. After the installation, create docker-compose-yml file with:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&lt;code&gt;version: &quot;3.9&quot;&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;services:&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;mssql:&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;image: mcr.microsoft.com/azure-sql-edge/developer&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;container_name: &quot;mssql&quot;&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;environment:&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;ACCEPT_EULA: &quot;Y&quot;&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;SA_PASSWORD: &quot;MySecretPassword&quot;&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;ports:&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;- &quot;1433:1433&quot;&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;volumes:&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;- ./data:/var/opt/mssql/data&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;- ./log:/var/opt/mssql/log&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&lt;code&gt;- ./secrets:/var/opt/mssql/secrets&lt;/code&gt;&lt;br /&gt;&lt;br /&gt;Start the container with SQL database by running &lt;code&gt;docker compose up&lt;/code&gt; command from the folder with .yml file.&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;br /&gt;To run Optimizely CMS locally you also need to install &lt;a href=&quot;https://dotnet.microsoft.com/en-us/download&quot;&gt;.Net&lt;/a&gt; framework for macOS. You may also need &lt;a href=&quot;https://nodejs.org/en/download/current&quot;&gt;Node.js&lt;/a&gt; if you have headless front-end.&lt;br /&gt;&lt;br /&gt;If you haven&#39;t install Optimizely PaaS CMS yet, you need to run few commands from Powershell CLI:&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;code&gt;dotnet dev-certs https --trust&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;br /&gt;&lt;code&gt;dotnet nuget add source http://nuget.episerver.com/feed/packages.svc -n Optimizely&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;br /&gt;&lt;code&gt;dotnet new -i EPiServer.Templates&lt;/code&gt;&lt;br /&gt;&lt;br /&gt;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;code&gt;dotnet tool install EPiServer.Net.Cli --global --add-source &lt;a href=&quot;https://nuget.optimizely.com/feed/packages.svc/&quot;&gt;https://nuget.optimizely.com/feed/packages.svc/&lt;/a&gt;&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;You can create a new project using &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/docs/installing-optimizely-net-5#install-an-empty-cms-site&quot;&gt;Optimizely CLI commands&lt;/a&gt;. To create new database run from your project folder:&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;code&gt;dotnet-episerver create-cms-database {You project}.csproj -S localhost -dn {You db name} -U sa -P {You SA password from docker-compose.yml}&lt;/code&gt;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&lt;br /&gt;&lt;code&gt;dotnet-episerver add-admin-user {Your project}.csproj -u [username] -e [email] -p [password] -c EPiServerDB&lt;/code&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Working with Code&lt;/h2&gt;
&lt;p&gt;To run and debug your Optimizely CMS project use &lt;a href=&quot;https://code.visualstudio.com/download&quot;&gt;Visual Studio Code&lt;/a&gt; with &lt;a href=&quot;https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit&amp;amp;WT.mc_id=dotnet-35129-website&quot;&gt;C# Dev Kit&lt;/a&gt; (for PaaS CMS). Other helpful extensions are &lt;a href=&quot;https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint&quot;&gt;ESLint&lt;/a&gt; to analyze javascript code and&amp;nbsp;&lt;a href=&quot;https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode&quot;&gt;Prettier&lt;/a&gt; to help keeping the code formatting clean, but you may find more extensions for your use case. TIP: can create &lt;code&gt;/{Your project root}/.vscode/extensions.json&lt;/code&gt; file with recommented VS Code extensions for your team.&lt;/p&gt;
&lt;h2&gt;Database Management&lt;/h2&gt;
&lt;p&gt;To manage local database server and migrate the database backup exported from DXP PaaS portal use&amp;nbsp;&lt;a href=&quot;https://azure.microsoft.com/en-us/products/data-studio&quot;&gt;Azure Data Studio&lt;/a&gt; for macOS with&amp;nbsp;&lt;a href=&quot;https://learn.microsoft.com/en-us/azure-data-studio/extensions/sql-server-dacpac-extension&quot;&gt;SQL Server dacpac extension&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;First connect to your local SQL instance on Docker (use localhost server):&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/25ef1a11b28e4f828bfcbeceff369455.aspx&quot; alt=&quot;&quot; width=&quot;450&quot; height=&quot;460&quot; /&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;Then right click on Databases and select &quot;Data-tier Application Wizard&quot;. Select &quot;Create a database from a .backpac file&quot;. Choose a backpac file exported from PaaS Portal and import it under new name.&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;img src=&quot;/link/7b3162eae55d4a828a52bd56b5c97db9.aspx&quot; alt=&quot;&quot; width=&quot;450&quot; height=&quot;356&quot; /&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;After the import is done, select new database and navigate to &#39;Security&#39;-&amp;gt;&#39;Users&#39;, right click on the &#39;Users&#39; folder and click on &#39;New User&#39;. In &#39;User name&#39; use the same name as you had before in your local instance, in &#39;Login name&#39; use the same login as you had before, in &#39;Default schema&#39; select dbo, in &#39;Owned schemas&#39; use db_owner, in &#39;Membership&#39; select db_owner.&lt;/div&gt;
&lt;br /&gt;
&lt;div&gt;Update connection string in your &lt;code&gt;{You web project folder}\appsettings.Development.json&lt;/code&gt; so it points to new database name.&amp;nbsp;After login to Optimizely on your local, go to settings sections: &quot;Manage Websites&quot; and &quot;Open ID Connect&quot; and update hostnames to point to your localhost.&lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Assets Management&lt;/h2&gt;
&lt;p&gt;To import assets from Optimizely DXP you need Powershell CLI and &lt;a href=&quot;https://azure.microsoft.com/en-us/products/storage/storage-explorer&quot;&gt;Azure Storage Explorer&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Using Powershell command line install EpiCloud Powershell CLI: &lt;code&gt;Install-Module -Name EpiCloud&lt;/code&gt; &lt;br /&gt;Then connect to the cloud on DXP environment by running Powershell: &lt;code&gt;Connect-EpiCloud -ClientKey {your client key} -ClientSecret {your client secret} -ProjectId {your project id}&lt;/code&gt; you can find all necessary connection details in &lt;a href=&quot;https://paasportal.episerver.net/&quot;&gt;PaaS Portal&lt;/a&gt; under API tab.&lt;/p&gt;
&lt;p&gt;Finally generate your &lt;strong&gt;sasLink&lt;/strong&gt; to media storage by calling Powershell: &lt;code&gt;Get-EpiStorageContainerSasLink -Environment &quot;{name of the envionment for example: Integration}&quot; -StorageContainer &quot;mysitemedia&quot; -RetentionHours 1 | Format-Table sasLink -AutoSize -Wrap&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;In&amp;nbsp;Azure Storage Explorer&amp;nbsp;click &quot;Open Connection Dialog&quot;, select &#39;Blob Container or directory&#39; and use &#39;Shared access signature URL (SAS)&#39; option. In &#39;Blob container or directory SAS URL&#39; paste &lt;strong&gt;sasLink&lt;/strong&gt; from previous command.&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;img src=&quot;/link/879b6504163644fe907d56111c30abc1.aspx&quot; alt=&quot;&quot; width=&quot;600&quot; height=&quot;242&quot; /&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;Open Blob Container and expand &#39;Download&#39; button and use &#39;Download All&#39; to download entire storage to local folder. Move the downloaded folder content to &lt;code&gt;{You web project folder}\App_Data\blobs\&lt;/code&gt;.&lt;/div&gt;
&lt;/div&gt;</description>            <guid>https://world.optimizely.com/blogs/tomek-juranek/dates/2025/3/optimizely-developer-tools-for-macos/</guid>            <pubDate>Sat, 15 Mar 2025 14:18:27 GMT</pubDate>           <category>Blog post</category></item></channel>
</rss>