
How to Capture Published Page URLs in EpiServer 12.20.0
Upgrading to EpiServer (now Optimizely CMS) 12.20.0 can break your existing URL capture logic during publish events. If you're suddenly getting null URLs or your event handlers aren't firing, you're not alone. This guide walks you through the reliable approach to capture published page URLs in the upgraded version, with working code examples and troubleshooting tips.
Prerequisites
Before implementing URL capture in EpiServer 12.20.0, make sure you have:
- EpiServer CMS 12.20.0 or later installed
- .NET 6 or higher runtime
- Basic understanding of dependency injection in .NET
- Access to modify your site's startup configuration
- Understanding of EpiServer's content events system
Your project should already have the core EpiServer packages referenced:
- EPiServer.CMS.Core
- EPiServer.CMS.UI
Step 1: Create the Event Handler Class
Start by creating a dedicated class to handle published content events. The key change in 12.20.0 is properly using dependency injection instead of the deprecated ServiceLocator pattern.
using EPiServer;
using EPiServer.Core;
using EPiServer.Web.Routing;
using Microsoft.Extensions.Logging;
public class PageUrlCaptureHandler
{
private readonly IContentEvents _contentEvents;
private readonly IUrlResolver _urlResolver;
private readonly ILogger _logger;
public PageUrlCaptureHandler(
IContentEvents contentEvents,
IUrlResolver urlResolver,
ILogger logger)
{
_contentEvents = contentEvents;
_urlResolver = urlResolver;
_logger = logger;
// Subscribe to the published content event
_contentEvents.PublishedContent = OnContentPublished;
}
private void OnContentPublished(object sender, ContentEventArgs e)
{
if (e.Content is PageData publishedPage)
{
CapturePageUrl(publishedPage);
}
}
private void CapturePageUrl(PageData page)
{
// Implementation in next step
}
}Step 2: Implement URL Capture Logic
Now add the actual URL capture logic. The crucial part is using the correct VirtualPathArguments with ContextMode.Default to get public URLs.
private void CapturePageUrl(PageData page)
{
try
{
// Handle single-language sites
if (page.ExistingLanguages.Count() == 1)
{
var url = GetPublicUrl(page.ContentLink, null);
if (!string.IsNullOrEmpty(url))
{
ProcessCapturedUrl(url, page.Name, null);
}
return;
}
// Handle multi-language sites
foreach (var language in page.ExistingLanguages)
{
var url = GetPublicUrl(page.ContentLink, language.Name);
if (!string.IsNullOrEmpty(url))
{
ProcessCapturedUrl(url, page.Name, language.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to capture URL for page {PageId}", page.ContentLink.ID);
}
}
private string GetPublicUrl(ContentReference contentRef, string language)
{
var args = new VirtualPathArguments
{
ContextMode = ContextMode.Default,
ForceCanonical = true
};
return _urlResolver.GetUrl(contentRef, language, args);
}
private void ProcessCapturedUrl(string url, string pageName, string language)
{
// Your custom logic here - examples:
// - Log to database
// - Send to analytics service
// - Update sitemap
// - Trigger webhook
var languageInfo = string.IsNullOrEmpty(language) ? "default" : language;
_logger.LogInformation("Captured URL: {Url} for page '{PageName}' (Language: {Language})",
url, pageName, languageInfo);
}Step 3: Register the Handler in Dependency Injection
Add your handler to the service container in your Startup.cs or Program.cs file:
public void ConfigureServices(IServiceCollection services)
{
// Your existing EpiServer configuration...
// Register the URL capture handler as singleton
services.AddSingleton();
// Alternative: Register as scoped if you need per-request services
// services.AddScoped();
}We've found that registering as a singleton works best for event handlers since they need to persist throughout the application lifecycle.
Step 4: Handle Edge Cases and Validation
Add validation to handle pages that might not have public URLs:
private string GetPublicUrl(ContentReference contentRef, string language)
{
// Check if content reference is valid
if (ContentReference.IsNullOrEmpty(contentRef))
return null;
var args = new VirtualPathArguments
{
ContextMode = ContextMode.Default,
ForceCanonical = true
};
var url = _urlResolver.GetUrl(contentRef, language, args);
// Additional validation
if (string.IsNullOrEmpty(url) || url.StartsWith("~/"))
{
_logger.LogWarning("Could not resolve public URL for content {ContentId} (Language: {Language})",
contentRef.ID, language ?? "default");
return null;
}
return url;
}Step 5: Add Support for Multi-Site Scenarios
If you're running multiple sites, you might need site-specific URL generation:
private readonly ISiteDefinitionResolver _siteResolver;
// Add to constructor parameters
public PageUrlCaptureHandler(
IContentEvents contentEvents,
IUrlResolver urlResolver,
ISiteDefinitionResolver siteResolver,
ILogger logger)
{
_contentEvents = contentEvents;
_urlResolver = urlResolver;
_siteResolver = siteResolver;
_logger = logger;
_contentEvents.PublishedContent = OnContentPublished;
}
private string GetPublicUrlWithSite(ContentReference contentRef, string language)
{
var site = _siteResolver.GetByContent(contentRef, true);
if (site == null)
return GetPublicUrl(contentRef, language);
var args = new VirtualPathArguments
{
ContextMode = ContextMode.Default,
ForceCanonical = true
};
return _urlResolver.GetUrl(contentRef, language, args);
}Common Mistakes to Avoid
Using ServiceLocator Pattern
// DON'T do this - deprecated in 12.20.0 var urlResolver = ServiceLocator.Current.GetInstance();
Forgetting to Register the Handler
If your event handler isn't firing, check that it's properly registered in DI. The container needs to instantiate it for the event subscription to work.
Not Handling Null URLs
Pages without templates or those marked as non-routable will return null URLs. Always check for null before processing.
Incorrect Context Mode
// DON'T use Edit mode for public URLs
var args = new VirtualPathArguments { ContextMode = ContextMode.Edit };
// DO use Default mode for public URLs
var args = new VirtualPathArguments { ContextMode = ContextMode.Default };Memory Leaks from Event Subscriptions
If you're not using DI properly, make sure to unsubscribe from events to prevent memory leaks:
public void Dispose()
{
_contentEvents.PublishedContent -= OnContentPublished;
}Testing and Verification
Test Your Implementation
Create a simple test page to verify URL capture is working:
- Create a new page in the EpiServer admin
- Publish the page
- Check your logs for the captured URL entry
- Verify the URL is accessible and correct
Debug Common Issues
No URLs Being Captured:
- Verify the handler is registered in DI
- Check that the page has a template assigned
- Confirm the page is set to be published (not just saved as draft)
Partial URLs (~/page-name):
- Ensure you're using ContextMode.Default
- Check your site's hostname configuration
- Verify ForceCanonical = true in VirtualPathArguments
Working with teams has taught us that the most common issue is forgetting to register the handler properly in the DI container.
Performance Considerations
For high-traffic sites, consider making URL processing asynchronous:
private async void OnContentPublished(object sender, ContentEventArgs e)
{
if (e.Content is PageData publishedPage)
{
// Process URL capture in background
_ = Task.Run(() => CapturePageUrl(publishedPage));
}
}Complete Working Example
Here's the full implementation ready to use:
using EPiServer;
using EPiServer.Core;
using EPiServer.Web.Routing;
using Microsoft.Extensions.Logging;
public class PageUrlCaptureHandler
{
private readonly IContentEvents _contentEvents;
private readonly IUrlResolver _urlResolver;
private readonly ILogger _logger;
public PageUrlCaptureHandler(
IContentEvents contentEvents,
IUrlResolver urlResolver,
ILogger logger)
{
_contentEvents = contentEvents;
_urlResolver = urlResolver;
_logger = logger;
_contentEvents.PublishedContent = OnContentPublished;
}
private void OnContentPublished(object sender, ContentEventArgs e)
{
if (e.Content is PageData publishedPage)
{
CapturePageUrl(publishedPage);
}
}
private void CapturePageUrl(PageData page)
{
try
{
foreach (var language in page.ExistingLanguages)
{
var url = GetPublicUrl(page.ContentLink, language.Name);
if (!string.IsNullOrEmpty(url))
{
ProcessCapturedUrl(url, page.Name, language.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to capture URL for page {PageId}", page.ContentLink.ID);
}
}
private string GetPublicUrl(ContentReference contentRef, string language)
{
if (ContentReference.IsNullOrEmpty(contentRef))
return null;
var args = new VirtualPathArguments
{
ContextMode = ContextMode.Default,
ForceCanonical = true
};
var url = _urlResolver.GetUrl(contentRef, language, args);
if (string.IsNullOrEmpty(url) || url.StartsWith("~/"))
{
return null;
}
return url;
}
private void ProcessCapturedUrl(string url, string pageName, string language)
{
_logger.LogInformation("Published page URL captured: {Url} ({PageName}, {Language})",
url, pageName, language);
// Add your custom processing logic here
}
}Don't forget to register it in your DI container:
services.AddSingleton();
Conclusion
Capturing published page URLs in EpiServer 12.20.0 requires adapting to the new dependency injection patterns and updated event handling. The key changes are using proper DI instead of ServiceLocator, ensuring correct VirtualPathArguments, and handling multi-language scenarios appropriately.
Our experience shows that most URL capture issues after upgrading stem from either improper DI registration or incorrect context mode settings. Following this guide should give you reliable URL capture that works consistently across different site configurations.
Need help implementing URL capture in your EpiServer 12.20.0 upgrade? We can review your existing event handling code and help you migrate to the new patterns while ensuring your analytics, logging, and integration systems continue working smoothly.
