Building a CMS-Agnostic Layout Service Response in Sitecore
Building a CMS-Agnostic Layout Service Response in Sitecore
Introduction
In modern headless architectures, frontend teams (especially Next.js) expect clean, predictable, and CMS-agnostic APIs. However, Sitecore Layout Service by default returns:
Nested
fields.valuestructuresMedia, links, and other field types follow Sitecore-specific structures
CMS-specific field types
This makes frontend consumption complex and tightly coupled to Sitecore.
In this blog, I’ll walk through how I built a CMS-agnostic Layout Service response, including solving one of the trickiest problems:
👉 Expanding Multilist fields dynamically with full field data
Problem Statement
Sitecore Layout Service provides a powerful way to expose content for headless applications. However, the default response is highly CMS-dependent and not ideal for modern frontend frameworks like Next.js.
For example:
-
Fields are wrapped in
fields.value - Field names are not normalized
- Media, links, and other field types follow Sitecore-specific structures
- Not frontend-friendly
- Tight coupling with Sitecore
Goal
- CMS-agnostic
- Ready for Next.js
- Clean
Solution Approach
We implemented a custom Rendering Contents Resolver to:
- Intercept Layout Service response
- Transform field structure
- Expand multilist items
- Normalize field types
Step 1: Custom Rendering Contents Resolver
Step 2: Map Items
private Dictionary<string, object> MapFields(Item item)
{
var dict = new Dictionary<string, object>();
foreach (Field field in item.Fields)
{
if (field == null) continue;
if (field.Name.StartsWith("__")) continue;
var fieldName = item.Template.GetField(field.ID)?.Name;
if (string.IsNullOrWhiteSpace(fieldName)) continue;
var key = ToCamelCase(fieldName);
var value = GetFieldValue(field);
if (value == null) continue;
dict[key] = value;
}
return dict;
}
private Dictionary<string, object> MapFields(Item item)
{
var dict = new Dictionary<string, object>();
foreach (Field field in item.Fields)
{
if (field == null) continue;
if (field.Name.StartsWith("__")) continue;
var fieldName = item.Template.GetField(field.ID)?.Name;
if (string.IsNullOrWhiteSpace(fieldName)) continue;
var key = ToCamelCase(fieldName);
var value = GetFieldValue(field);
if (value == null) continue;
dict[key] = value;
}
return dict;
}
Step 3: Handle Field Types
private object GetFieldValue(Field field)
{
if (field == null) return null;
var type = field.TypeKey?.ToLower() ?? "";
//MULTILIST / TREELIST/DropList
if (type.Contains("list") || type.Contains("tree"))
{
var multilist = new MultilistField(field);
var items = multilist.GetItems();
if (items == null || !items.Any())
return null;
return items.Select(x => MapFields(x)).ToList();
}
// IMAGE
if (type == "image")
{
var image = new ImageField(field);
if (image?.MediaItem == null)
return null;
return new
{
url = Sitecore.Resources.Media.MediaManager.GetMediaUrl(image.MediaItem),
alt = image.Alt ?? ""
};
}
//CHECKBOX
if (type == "checkbox")
{
return new CheckboxField(field).Checked;
}
// LINK
if (type == "general link" || type == "link")
{
var link = new LinkField(field);
return new
{
url = link.GetFriendlyUrl(),
text = link.Text,
target = link.Target
};
}
// DEFAULT (text, rich text, number etc.)
return string.IsNullOrWhiteSpace(field.Value) ? null : field.Value;
}
private object GetFieldValue(Field field)
{
if (field == null) return null;
var type = field.TypeKey?.ToLower() ?? "";
//MULTILIST / TREELIST/DropList
if (type.Contains("list") || type.Contains("tree"))
{
var multilist = new MultilistField(field);
var items = multilist.GetItems();
if (items == null || !items.Any())
return null;
return items.Select(x => MapFields(x)).ToList();
}
// IMAGE
if (type == "image")
{
var image = new ImageField(field);
if (image?.MediaItem == null)
return null;
return new
{
url = Sitecore.Resources.Media.MediaManager.GetMediaUrl(image.MediaItem),
alt = image.Alt ?? ""
};
}
//CHECKBOX
if (type == "checkbox")
{
return new CheckboxField(field).Checked;
}
// LINK
if (type == "general link" || type == "link")
{
var link = new LinkField(field);
return new
{
url = link.GetFriendlyUrl(),
text = link.Text,
target = link.Target
};
}
// DEFAULT (text, rich text, number etc.)
return string.IsNullOrWhiteSpace(field.Value) ? null : field.Value;
}
Step 4: Normalize Field Names
private string ToCamelCase(string input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
var cleaned = input.Replace(" ", "").Replace("-", "");
return char.ToLowerInvariant(cleaned[0]) + cleaned.Substring(1);
}
private string ToCamelCase(string input)
{
if (string.IsNullOrWhiteSpace(input)) return null;
var cleaned = input.Replace(" ", "").Replace("-", "");
return char.ToLowerInvariant(cleaned[0]) + cleaned.Substring(1);
}
Step 5: Create Patch Configuration
Now register your resolver in Sitecore using a patch config file.
📁 Path: /App_Config/Include/Project/YourProject/LayoutService.CleanJson.config
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"><sitecore> <layoutService> <renderingContentsResolvers> <resolver id="CleanJsonResolver" type="YourNamespace.CleanJsonResolver, YourAssembly" /> </renderingContentsResolvers> </layoutService> </sitecore> </configuration>
Step 6: Register the Resolver in Sitecore (Item Level)
After adding the patch config, you also need to create/register the resolver
Navigate in Sitecore Content Editor
After adding the patch config, you also need to create/register the resolver
Navigate in Sitecore Content Editor
Go to:
/sitecore/system/Modules/Layout Service/Rendering Contents Resolvers
Create New Resolver Item
- Right-click → Insert
- Select Rendering Contents Resolver
- Name it:CleanJsonResolver(Or give your custom resolver name)
- Configure the Resolver Item : Type: YourNamespace.CleanJsonResolver, YourAssembly
Go to:
/sitecore/system/Modules/Layout Service/Rendering Contents Resolvers
Create New Resolver Item
- Right-click → Insert
- Select Rendering Contents Resolver
- Name it:CleanJsonResolver(Or give your custom resolver name)
- Configure the Resolver Item : Type: YourNamespace.CleanJsonResolver, YourAssembly
Step 7: Assign Resolver to Rendering
Go to Sitecore:
/sitecore/layout/Renderings/...
- Open your rendering (e.g.,
testjson) - Find “Rendering Contents Resolver”
- Select:
CleanJsonResolver
Challenges Faced (Important Learnings)
1. Multilist fields return GUIDs by default
Solution: Use MultilistField.GetItems()
2. System fields noise
Filtered using:
field.Name.StartsWith("__")
Key Takeaways
Layout Service is not frontend-ready by default
Multilist requires manual expansion
Custom resolvers unlock true headless power
Layout Service is not frontend-ready by default
Multilist requires manual expansion
Custom resolvers unlock true headless power
🚀 Final Result
You now have:
✔ CMS-agnostic JSON
✔ Fully expanded multilist
✔ Clean structure for frontend
✔ No GUIDs
✔ No Sitecore coupling
💬 Conclusion
This approach bridges the gap between:
👉 Traditional Sitecore → Modern Headless Architecture
By customizing the Layout Service response, you empower frontend teams to work independently with clean APIs — exactly what headless is meant for.
If you're working on Sitecore + Next.js and struggling with Layout Service responses — this pattern will save you a lot of time.
Happy coding! 🚀
Comments
Post a Comment