If you have worked on any Optimizely CMS 12 codebase, chances are you have written code like this in a controller, block component, or view model factory:
// CMS 12 pattern
if (currentPage.MainContentArea?.FilteredItems?.Any() == true)
{
var visibleBlocks = currentPage.MainContentArea.FilteredItems
.Select(item => _contentLoader.Get<IContent>(item.ContentLink))
.ToList();
}
In Optimizely CMS 13, ContentArea.FilteredItems is marked [Obsolete] and scheduled for complete removal in CMS 14. If you enable <TreatWarningsAsErrors>true</TreatWarningsAsErrors> as recommended for your CMS 13 upgrade, any direct reference to FilteredItems will fail your build.
Here is why Optimizely made this change, when you actually need to fix it, and how to implement a clean, dependency-injection-friendly replacement in CMS 13.
Why ContentArea.FilteredItems Was Deprecated
ContentArea is supposed to be a pure content data model (a POCO). However, in CMS 12 and earlier versions, calling contentArea.FilteredItems forced the property getter to secretly call a static ServiceLocator behind the scenes to resolve permissions, publish status, and visitor group evaluators.
This hidden service locator dependency broke clean architecture principles, made unit testing difficult, and caused issues with asynchronous execution pipelines in modern ASP.NET Core.
In CMS 13, Optimizely separated data from evaluation: ContentArea holds only raw items (contentArea.Items), while filtering is handled explicitly by injected services.
When You Do (and Do Not) Need to Change Your Code
1. Standard Razor Views: No Change Needed
If you render content areas in your Razor views using tag helpers or the standard HTML helper:
<!-- No migration needed -->
<epi-property property-name="MainContentArea" />
@Html.PropertyFor(x => x.CurrentPage.MainContentArea)
The internal ContentAreaRenderer pipeline automatically applies all access checks and personalization filters during HTML generation, exactly as it did in CMS 12.
2. C# Backend Code: Migration Required
If you access FilteredItems directly in C# (such as checking whether a content area has visible blocks for the current user, calculating item counts, or mapping blocks to custom DTOs), you must migrate.
Warning: Never blindly replace contentArea.FilteredItems with contentArea.Items. The Items collection contains raw, unfiltered references, including expired blocks, unpublished drafts, and blocks restricted to specific Visitor Groups or roles. Doing so will leak restricted content to anonymous visitors.
The CMS 13 Replacement: IContentAreaItemsRenderingFilter
To evaluate item visibility in C#, CMS 13 provides the IContentAreaItemsRenderingFilter interface. Optimizely registers two built-in filter implementations out of the box:
ContentAreaItemAccessFilter: Evaluates content access permissions and publishing status.VisitorGroupContentAreaItemsFilter: Evaluates visitor group criteria for the current request.
You can inject IEnumerable<IContentAreaItemsRenderingFilter> into any controller, service, or factory and run the filters against contentArea.Items.
Step-by-Step Implementation
Option 1: Injecting Filters Directly into a Controller or Service
using EPiServer.Core;
using EPiServer.Cms.Shell.UI.Rest.ContentAreas;
using Microsoft.AspNetCore.Mvc;
public class ArticlePageController : PageController<ArticlePage>
{
private readonly IEnumerable<IContentAreaItemsRenderingFilter> _contentAreaFilters;
private readonly IContentLoader _contentLoader;
public ArticlePageController(
IEnumerable<IContentAreaItemsRenderingFilter> contentAreaFilters,
IContentLoader contentLoader)
{
_contentAreaFilters = contentAreaFilters;
_contentLoader = contentLoader;
}
public IActionResult Index(ArticlePage currentPage)
{
var rawItems = currentPage.MainContentArea?.Items ?? Enumerable.Empty<ContentAreaItem>();
// Apply built-in CMS 13 filters sequentially
var filteredItems = rawItems;
foreach (var filter in _contentAreaFilters)
{
filteredItems = filter.FilterItems(filteredItems);
}
var visibleBlocks = filteredItems
.Select(item => _contentLoader.Get<IContent>(item.ContentLink))
.ToList();
var model = new ArticlePageViewModel(currentPage)
{
VisibleBlocks = visibleBlocks,
HasVisibleContent = visibleBlocks.Any()
};
return View(model);
}
}
Option 2: Creating a Reusable Extension Helper
To avoid repeating the foreach loop across multiple controllers and services, wrap the logic in a dedicated service or extension helper:
using EPiServer.Core;
public interface IContentAreaFilterService
{
IEnumerable<ContentAreaItem> Filter(ContentArea? contentArea);
bool HasVisibleItems(ContentArea? contentArea);
}
public class ContentAreaFilterService : IContentAreaFilterService
{
private readonly IEnumerable<IContentAreaItemsRenderingFilter> _filters;
public ContentAreaFilterService(IEnumerable<IContentAreaItemsRenderingFilter> filters)
{
_filters = filters;
}
public IEnumerable<ContentAreaItem> Filter(ContentArea? contentArea)
{
if (contentArea?.Items == null || !contentArea.Items.Any())
{
return Enumerable.Empty<ContentAreaItem>();
}
var items = contentArea.Items.AsEnumerable();
foreach (var filter in _filters)
{
items = filter.FilterItems(items);
}
return items;
}
public bool HasVisibleItems(ContentArea? contentArea)
{
return Filter(contentArea).Any();
}
}
Register the service in Program.cs or Startup.cs:
services.AddScoped<IContentAreaFilterService, ContentAreaFilterService>();
Now any controller or view model factory can check visibility cleanly:
if (_filterService.HasVisibleItems(currentPage.SidebarContentArea))
{
// Render sidebar section
}
Related API Note: FilterAccess vs IContentAccessEvaluator
While upgrading access evaluation code, keep in mind that direct calls to FilterAccess.QueryDistinctAccessEdit() and FilterAccess.QueryDistinctAccess() are also obsolete in CMS 13. Replace direct FilterAccess static calls with the injected IContentAccessEvaluator service:
// Legacy CMS 12 call (Obsolete)
// var hasAccess = FilterAccess.QueryDistinctAccess(content, AccessLevel.Read);
// Recommended CMS 13 replacement
var hasAccess = _contentAccessEvaluator.HasAccess(content, context.User, AccessLevel.Read);
Important
- Do not swap
contentArea.FilteredItemswithcontentArea.Itemswithout filtering.Itemsbypasses visitor group personalization and permissions entirely. IContentAreaItemsRenderingFilterruns synchronously in memory over the loaded item collection, but the underlying content items may still need to be loaded viaIContentLoader. When loading items from links, use batch loading methods like_contentLoader.GetItems(links, options)rather than looping singleGet<T>()calls.- Custom filters can be created by implementing
IContentAreaItemsRenderingFilterand adding them to the DI container. This is useful if you have custom schedule, device, or geo-targeting rules outside standard Visitor Groups. - Visitor groups only function in MVC/coupled rendering setups. If you plan to move content to Optimizely Graph for headless consumption, Visitor Groups are not indexed; use CMS 13 Content Variations and Optimizely Experimentation instead.


