Adding Server-Side JSON-LD to Sitecore Pages for AI Search
Your SEO team just rolled out schema markup across the site through Google Tag Manager, the Rich Results Test is showing green checkmarks, and everyone is happy… until someone asks ChatGPT who wrote your latest article and it has no idea. The markup is there, so what happened? The answer is that most AI crawlers never run JavaScript, so anything injected after the page loads simply doesn’t exist to them.
The following walks through why server-rendered structured data matters for AI search, the manual ways to get JSON-LD into your Sitecore pages, and what it takes to generate it dynamically from your content for both Sitecore XP/XM (MVC) and headless SitecoreAI (formerly XM Cloud).
IMPORTANT: If your Sitecore pages render their main content on the client side… stop here and fix that first. Structured data is a reinforcing signal, and it won’t help much if the crawler receives an empty shell where your content should be. Get your content into the server HTML, then come back and add the schema.
What Are We Actually Talking About?
“Server-side schema” gets thrown around a lot, but it isn’t really a standard term. What you will see in Google’s documentation is structured data, the SEO world tends to call it schema markup (after the schema.org vocabulary of types like Article, Product and Organization), and the format itself is JSON-LD, which is the W3C standard Google recommends.
Put it all together and what we want is server-rendered JSON-LD structured data… in other words, schema.org markup written as JSON-LD that is already sitting in the HTML when Sitecore sends the response, before a single line of JavaScript runs.
Why Server-Side Schema Matters for AI Search
Back in December 2024, Vercel and MERJ published a crawl-log study that found no evidence of OpenAI’s crawlers (GPTBot, OAI-SearchBot and ChatGPT-User) or Anthropic’s ClaudeBot rendering JavaScript. They will download your script files from time to time, but they never execute them. Google’s Gemini is the exception here since it rides on Googlebot’s rendering infrastructure.
Even Google treats JavaScript rendering as a second pass. Googlebot queues pages for rendering after the initial crawl, so markup in the first HTML response gets processed right away, while injected markup depends on that render succeeding later.
Google has also been clear that structured data plays a role in its AI features. In its guidance for AI Overviews and AI Mode, one of the top recommendations is to make sure your structured data matches the visible content on the page and that you validate it. That makes sense when you think about it, as explicit facts like an article’s author and publish date, a product’s price and availability, or your organization’s official profiles give the machines something unambiguous to anchor to.
A few caveats to consider
There is a lot of vendor content out there overselling this topic, so let’s be honest with our stakeholders about the limits:
- Structured data does not “prevent hallucinations.” It reduces ambiguity, but it does not control what a model generates.
- Outside of Google and Microsoft, the AI vendors have not publicly documented whether their retrieval pipelines read JSON-LD at all, and some pipelines strip script blocks when converting HTML to text.
- The visible, server-rendered content on your page matters more than the markup, which is exactly why the IMPORTANT note at the top of this post comes first.
The Rules Your Markup Needs to Follow
Before getting into the Sitecore specifics, there are a handful of rules that apply no matter which approach you take.
- Your JSON-LD goes in a
<script type="application/ld+json">block, which can live in either the<head>or the<body>. The head is the better choice in Sitecore since it lives in the layout and is easier to govern. - Every value in the markup must match what is visible on the page. Hidden or mismatched markup violates Google’s structured data policies, and while having it ignored is the mild outcome, a manual action is the not-so-mild one.
- The block needs to be in the server response itself, so verify with
curlrather than browser dev tools (dev tools show you the DOM after JavaScript has run, which is not what the AI crawlers see). - Use
@graphwith stable@idvalues so your Organization, WebSite, WebPage, Article and author all reference each other as one connected graph instead of floating around as disconnected fragments. - Finally, always serialize with a JSON library and escape
<as\u003c. If an editor ever types</script>into a field and you are concatenating strings, you just broke the page.
As for which schema.org types are worth your time, focus on Organization and WebSite (once per page, and they are the first two @type nodes in the example graph below), WebPage with a BreadcrumbList (every page, and breadcrumbs map naturally to the Sitecore content tree), BlogPosting or Article for article templates, and Product for product templates.
You will see a lot of advice pushing FAQPage, but take special note that since August 2023, Google only shows FAQ rich results for well-known government and health sites, and it retired HowTo rich results entirely in September 2023. FAQPage is still valid semantic markup… just don’t expect a rich result from it.
Here is the target output for a blog post. Every implementation below produces this shape:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://www.example.com/#organization",
"name": "Example Corp",
"url": "https://www.example.com/",
"logo": {
"@type": "ImageObject",
"url": "https://www.example.com/-/media/brand/logo.png"
},
"sameAs": [
"https://www.linkedin.com/company/example-corp",
"https://www.crunchbase.com/organization/example-corp"
]
},
{
"@type": "WebSite",
"@id": "https://www.example.com/#website",
"name": "Example Corp",
"url": "https://www.example.com/",
"publisher": { "@id": "https://www.example.com/#organization" }
},
{
"@type": "WebPage",
"@id": "https://www.example.com/insights/json-ld-in-sitecore#webpage",
"url": "https://www.example.com/insights/json-ld-in-sitecore",
"name": "Server-Rendered JSON-LD in Sitecore",
"isPartOf": { "@id": "https://www.example.com/#website" },
"breadcrumb": { "@id": "https://www.example.com/insights/json-ld-in-sitecore#breadcrumb" }
},
{
"@type": "BreadcrumbList",
"@id": "https://www.example.com/insights/json-ld-in-sitecore#breadcrumb",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://www.example.com/" },
{ "@type": "ListItem", "position": 2, "name": "Insights", "item": "https://www.example.com/insights" },
{ "@type": "ListItem", "position": 3, "name": "Server-Rendered JSON-LD in Sitecore", "item": "https://www.example.com/insights/json-ld-in-sitecore" }
]
},
{
"@type": "BlogPosting",
"@id": "https://www.example.com/insights/json-ld-in-sitecore#article",
"headline": "Server-Rendered JSON-LD in Sitecore",
"description": "How to deliver JSON-LD structured data in Sitecore's server HTML for AI search.",
"datePublished": "2026-10-07T09:00:00-04:00",
"dateModified": "2026-10-07T09:00:00-04:00",
"image": "https://www.example.com/-/media/insights/json-ld-hero.jpg",
"mainEntityOfPage": { "@id": "https://www.example.com/insights/json-ld-in-sitecore#webpage" },
"publisher": { "@id": "https://www.example.com/#organization" },
"author": {
"@type": "Person",
"@id": "https://www.example.com/authors/jane-doe#person",
"name": "Jane Doe",
"url": "https://www.example.com/authors/jane-doe",
"sameAs": ["https://www.linkedin.com/in/janedoe"]
}
}
]
}
Manual Options
The manual options are fast to ship and perfectly fine for a small set of pages, but keep in mind they drift over time. Every content edit is a chance for the markup and the page to fall out of sync, so think of these as a starting point (or an override) rather than the long-term strategy.
Hard-Coding Site-Wide Schema in the Layout
Your Organization and WebSite entities are the same on every page and rarely change, so the simplest thing to do is put them straight into your MVC layout as a partial view. This is the right answer for these two entities even after you build out dynamic generation, as it is cheap, stable and server-rendered by definition.
Take special note of the Razor gotcha here… the @ symbol starts a code expression in Razor, so every JSON-LD keyword needs to be written as @@context, @@type and so on. Create the following partial view and include it in the <head> of your main layout:
@* Views/Shared/_SiteSchema.cshtml
Hard-coded Organization + WebSite JSON-LD.
Include in the <head> of your main layout with: @Html.Partial("~/Views/Shared/_SiteSchema.cshtml")
Razor note: "@@" renders a literal "@". *@
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@graph": [
{
"@@type": "Organization",
"@@id": "https://www.example.com/#organization",
"name": "Example Corp",
"url": "https://www.example.com/",
"logo": {
"@@type": "ImageObject",
"url": "https://www.example.com/-/media/brand/logo.png"
},
"sameAs": [
"https://www.linkedin.com/company/example-corp",
"https://www.crunchbase.com/organization/example-corp"
]
},
{
"@@type": "WebSite",
"@@id": "https://www.example.com/#website",
"name": "Example Corp",
"url": "https://www.example.com/",
"publisher": { "@@id": "https://www.example.com/#organization" }
}
]
}
</script>
If you are running a multisite instance, you will want to move these values into a settings item on each site root instead, which is covered in the dynamic section below.
An Editor-Managed JSON Field
For a handful of high-value pages, you can let editors manage the JSON directly. Create a base template named _Structured Data with a multi-line text field named Structured Data JSON and inherit it into your page templates. Keep this field versioned so each language version can carry its own markup.
From there, add a view rendering to the head placeholder of your layout that reads the field, validates the JSON and only outputs it if it parses. Set the rendering to Cacheable with Vary By Data:
@* Views/Schema/PageJsonLd.cshtml
View rendering: outputs the page's "Structured Data JSON" field as JSON-LD.
Place in the head placeholder of your layout. Cache: Cacheable + Vary By Data.
Invalid JSON is logged and suppressed rather than rendered. *@
@using Newtonsoft.Json
@using Newtonsoft.Json.Linq
@using Sitecore.Diagnostics
@using Sitecore.Mvc.Presentation
@{
var page = PageContext.Current.Item;
string output = null;
if (page != null)
{
var raw = page["Structured Data JSON"];
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
var token = JToken.Parse(raw);
// Re-serialize to normalize, then escape "<" so a value can never close the script tag.
output = token.ToString(Formatting.None).Replace("<", "\\u003c");
}
catch (JsonReaderException ex)
{
Log.Warn("Invalid JSON-LD in " + page.Paths.FullPath + ": " + ex.Message, this);
}
}
}
}
@if (output != null)
{
<script type="application/ld+json">@Html.Raw(output)</script>
}
The weakness here is governance, not code. An editor pastes JSON from an online generator, the headline changes six months later, and nobody remembers to update the markup. If you go this route, add a field validator that runs a JSON check and treat the field as an override for exceptional pages rather than your primary source.
What About Google Tag Manager?
This approach is common because it doesn’t require a deployment… but injecting JSON-LD through Google Tag Manager defeats the entire purpose of this post. The markup only exists after JavaScript runs, so GPTBot, ClaudeBot and the other non-rendering crawlers never see it, and even Google only sees it after the render pass. If you have inherited a site that does this, migrating that markup into Sitecore is a quick win.
Dynamically Generating the Schema
This is where things get interesting. Rather than relying on editors to remember to update JSON, we build the markup at render time from the same Sitecore fields that produce the visible page, which means content parity is guaranteed by design.
The approach is the same whether you are on XP/XM or headless. Each page template maps to a schema.org type (Article Page to BlogPosting, Product Page to Product, etc.), and each schema property reads a specific field that the page already renders. Organization data lives on the site root item so every site in a multisite instance has its own. A single builder then assembles the Organization, WebSite, WebPage and BreadcrumbList nodes, adds the type-specific node, and links them all together with @id. Pages without a mapping still get WebPage and BreadcrumbList, and the manual JSON field from earlier sticks around as an override, along with a checkbox for pages that should opt out entirely.
Setting Up the Content Model
Before writing any code, set up (or map to) the following base templates. Use your existing field names wherever they already exist, as the whole point is that the builder reads what the page already shows:
| Base Template | Applies To | Fields |
|---|---|---|
| _Site Schema Settings | Site root (home) item | Organization Name, Organization Logo (image), Same As (multi-line, one URL per line) |
| _Page Base | All pages | Title, Navigation Title, Summary, Image |
| _Structured Data | All pages | Structured Data JSON (multi-line), Exclude Structured Data (checkbox) |
| _Article Page | Articles and blog posts | Publish Date, Updated Date, Author (droplink to an Author item) |
| Author | Author items | Name, Profile URL, Same As |
| _Product Page | Product pages | Product Name, SKU, Brand, Price, Currency, Availability |
The Schema Graph Builder (XP/XM)
With the content model in place, the builder does the heavy lifting. It uses Newtonsoft.Json, which already ships with Sitecore, so there are no new dependencies to add (if you prefer strongly typed schema objects, the open-source Schema.NET library is a good alternative to working with raw JObject).
Take special note of the SchemaTemplates and SchemaFields classes at the top, as you will need to swap in your own base template IDs and align the field names with your solution:
// src/Feature/StructuredData/code/SchemaGraphBuilder.cs
// Builds a connected JSON-LD @graph for a Sitecore page from its own fields.
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Data.Managers;
using Sitecore.Diagnostics;
using Sitecore.Links;
using Sitecore.Links.UrlBuilders;
using Sitecore.Resources.Media;
namespace Example.Feature.StructuredData
{
/// <summary>Base template IDs. Replace these GUIDs with the IDs from your solution.</summary>
public static class SchemaTemplates
{
public static readonly ID ArticlePage = new ID("{11111111-1111-1111-1111-111111111111}");
public static readonly ID ProductPage = new ID("{22222222-2222-2222-2222-222222222222}");
}
/// <summary>Field names. Align these with your existing templates.</summary>
public static class SchemaFields
{
// _Site Schema Settings (site root item)
public const string OrganizationName = "Organization Name";
public const string OrganizationLogo = "Organization Logo";
public const string SameAs = "Same As";
// _Page Base and _Structured Data (every page)
public const string Title = "Title";
public const string NavigationTitle = "Navigation Title";
public const string Summary = "Summary";
public const string Image = "Image";
public const string StructuredDataJson = "Structured Data JSON";
public const string ExcludeStructuredData = "Exclude Structured Data";
// _Article Page
public const string PublishDate = "Publish Date";
public const string UpdatedDate = "Updated Date";
public const string Author = "Author";
// Author item
public const string AuthorName = "Name";
public const string AuthorProfileUrl = "Profile URL";
// _Product Page
public const string ProductName = "Product Name";
public const string Sku = "SKU";
public const string Brand = "Brand";
public const string Price = "Price";
public const string Currency = "Currency";
public const string Availability = "Availability";
}
public class SchemaGraphBuilder
{
/// <summary>
/// Returns the serialized JSON-LD (safe to place inside a script tag), or null when
/// the page opts out.
/// </summary>
public string Build(Item page, Item siteRoot)
{
Assert.ArgumentNotNull(page, nameof(page));
Assert.ArgumentNotNull(siteRoot, nameof(siteRoot));
if (MainUtil.GetBool(page[SchemaFields.ExcludeStructuredData], false))
{
return null;
}
var siteUrl = EnsureTrailingSlash(GetUrl(siteRoot));
var pageUrl = GetUrl(page);
var organizationId = siteUrl + "#organization";
var websiteId = siteUrl + "#website";
var webPageId = pageUrl + "#webpage";
var breadcrumbId = pageUrl + "#breadcrumb";
var graph = new JArray
{
BuildOrganization(siteRoot, siteUrl, organizationId),
BuildWebSite(siteRoot, siteUrl, websiteId, organizationId),
BuildWebPage(page, pageUrl, webPageId, websiteId, breadcrumbId),
BuildBreadcrumbs(page, siteRoot, breadcrumbId)
};
var template = TemplateManager.GetTemplate(page);
if (template != null && template.DescendsFromOrEquals(SchemaTemplates.ArticlePage))
{
graph.Add(BuildArticle(page, pageUrl, webPageId, organizationId));
}
else if (template != null && template.DescendsFromOrEquals(SchemaTemplates.ProductPage))
{
graph.Add(BuildProduct(page, pageUrl, webPageId));
}
AppendOverrides(page, graph);
var document = new JObject
{
["@context"] = "https://schema.org",
["@graph"] = graph
};
// Escape "<" so no field value can ever close the surrounding script tag.
return document.ToString(Formatting.None).Replace("<", "\\u003c");
}
private static JObject BuildOrganization(Item siteRoot, string siteUrl, string organizationId)
{
var organization = new JObject
{
["@type"] = "Organization",
["@id"] = organizationId,
["url"] = siteUrl
};
AddIfPresent(organization, "name", siteRoot[SchemaFields.OrganizationName]);
var logoUrl = GetImageUrl(siteRoot, SchemaFields.OrganizationLogo);
if (logoUrl != null)
{
organization["logo"] = new JObject { ["@type"] = "ImageObject", ["url"] = logoUrl };
}
var sameAs = SplitLines(siteRoot[SchemaFields.SameAs]);
if (sameAs.Count > 0)
{
organization["sameAs"] = new JArray(sameAs);
}
return organization;
}
private static JObject BuildWebSite(Item siteRoot, string siteUrl, string websiteId, string organizationId)
{
var website = new JObject
{
["@type"] = "WebSite",
["@id"] = websiteId,
["url"] = siteUrl,
["publisher"] = new JObject { ["@id"] = organizationId }
};
AddIfPresent(website, "name", siteRoot[SchemaFields.OrganizationName]);
return website;
}
private static JObject BuildWebPage(Item page, string pageUrl, string webPageId, string websiteId, string breadcrumbId)
{
var webPage = new JObject
{
["@type"] = "WebPage",
["@id"] = webPageId,
["url"] = pageUrl,
["name"] = GetTitle(page),
["isPartOf"] = new JObject { ["@id"] = websiteId },
["breadcrumb"] = new JObject { ["@id"] = breadcrumbId },
["inLanguage"] = page.Language.Name
};
AddIfPresent(webPage, "description", PlainText(page[SchemaFields.Summary]));
return webPage;
}
private static JObject BuildBreadcrumbs(Item page, Item siteRoot, string breadcrumbId)
{
// Ancestors come back root-first; keep only routable items inside this site.
var trail = page.Axes.GetAncestors()
.Where(a => a.ID == siteRoot.ID || a.Axes.IsDescendantOf(siteRoot))
.Where(HasLayout)
.ToList();
trail.Add(page);
var elements = new JArray();
var position = 1;
foreach (var crumb in trail)
{
elements.Add(new JObject
{
["@type"] = "ListItem",
["position"] = position++,
["name"] = GetNavigationTitle(crumb),
["item"] = crumb.ID == siteRoot.ID ? EnsureTrailingSlash(GetUrl(crumb)) : GetUrl(crumb)
});
}
return new JObject
{
["@type"] = "BreadcrumbList",
["@id"] = breadcrumbId,
["itemListElement"] = elements
};
}
private static JObject BuildArticle(Item page, string pageUrl, string webPageId, string organizationId)
{
var article = new JObject
{
["@type"] = "BlogPosting",
["@id"] = pageUrl + "#article",
["headline"] = GetTitle(page),
["mainEntityOfPage"] = new JObject { ["@id"] = webPageId },
["publisher"] = new JObject { ["@id"] = organizationId }
};
AddIfPresent(article, "description", PlainText(page[SchemaFields.Summary]));
AddIfPresent(article, "image", GetImageUrl(page, SchemaFields.Image));
var published = GetIsoDate(page, SchemaFields.PublishDate);
AddIfPresent(article, "datePublished", published);
// Only claim a modified date the page actually displays; fall back to the publish date.
AddIfPresent(article, "dateModified", GetIsoDate(page, SchemaFields.UpdatedDate) ?? published);
ReferenceField authorField = page.Fields[SchemaFields.Author];
var author = authorField?.TargetItem;
if (author != null)
{
var person = new JObject { ["@type"] = "Person" };
AddIfPresent(person, "name", author[SchemaFields.AuthorName]);
var profileUrl = author[SchemaFields.AuthorProfileUrl];
if (!string.IsNullOrWhiteSpace(profileUrl))
{
person["@id"] = profileUrl.Trim() + "#person";
person["url"] = profileUrl.Trim();
}
var authorSameAs = SplitLines(author[SchemaFields.SameAs]);
if (authorSameAs.Count > 0)
{
person["sameAs"] = new JArray(authorSameAs);
}
article["author"] = person;
}
return article;
}
private static JObject BuildProduct(Item page, string pageUrl, string webPageId)
{
var product = new JObject
{
["@type"] = "Product",
["@id"] = pageUrl + "#product",
["mainEntityOfPage"] = new JObject { ["@id"] = webPageId }
};
AddIfPresent(product, "name", page[SchemaFields.ProductName]);
AddIfPresent(product, "description", PlainText(page[SchemaFields.Summary]));
AddIfPresent(product, "image", GetImageUrl(page, SchemaFields.Image));
AddIfPresent(product, "sku", page[SchemaFields.Sku]);
var brand = page[SchemaFields.Brand];
if (!string.IsNullOrWhiteSpace(brand))
{
product["brand"] = new JObject { ["@type"] = "Brand", ["name"] = brand.Trim() };
}
var price = page[SchemaFields.Price];
var currency = page[SchemaFields.Currency];
if (!string.IsNullOrWhiteSpace(price) && !string.IsNullOrWhiteSpace(currency))
{
var offer = new JObject
{
["@type"] = "Offer",
["url"] = pageUrl,
["price"] = price.Trim(),
["priceCurrency"] = currency.Trim()
};
var availability = page[SchemaFields.Availability];
if (!string.IsNullOrWhiteSpace(availability))
{
offer["availability"] = "https://schema.org/" + availability.Trim();
}
product["offers"] = offer;
}
return product;
}
private static void AppendOverrides(Item page, JArray graph)
{
var raw = page[SchemaFields.StructuredDataJson];
if (string.IsNullOrWhiteSpace(raw))
{
return;
}
try
{
var token = JToken.Parse(raw);
JArray nodes;
if (token is JObject obj && obj["@graph"] is JArray innerGraph)
{
nodes = innerGraph;
}
else if (token is JArray array)
{
nodes = array;
}
else
{
nodes = new JArray(token);
}
foreach (var node in nodes.OfType<JObject>())
{
var copy = (JObject)node.DeepClone();
copy.Remove("@context");
graph.Add(copy);
}
}
catch (JsonReaderException ex)
{
Log.Warn("Invalid Structured Data JSON on " + page.Paths.FullPath + ": " + ex.Message, typeof(SchemaGraphBuilder));
}
}
private static string GetUrl(Item item)
{
var options = new ItemUrlBuilderOptions
{
AlwaysIncludeServerUrl = true,
LanguageEmbedding = LanguageEmbedding.Never,
LowercaseUrls = true
};
return LinkManager.GetItemUrl(item, options);
}
private static string GetImageUrl(Item item, string fieldName)
{
ImageField image = item.Fields[fieldName];
if (image?.MediaItem == null)
{
return null;
}
var options = new MediaUrlBuilderOptions { AlwaysIncludeServerUrl = true };
return MediaManager.GetMediaUrl(image.MediaItem, options);
}
private static string GetIsoDate(Item item, string fieldName)
{
DateField date = item.Fields[fieldName];
if (date == null || date.DateTime == DateTime.MinValue)
{
return null;
}
return DateUtil.ToServerTime(date.DateTime).ToString("yyyy-MM-ddTHH:mm:sszzz");
}
private static string GetTitle(Item item)
{
var title = item[SchemaFields.Title];
return string.IsNullOrWhiteSpace(title) ? item.DisplayName : PlainText(title);
}
private static string GetNavigationTitle(Item item)
{
var navigationTitle = item[SchemaFields.NavigationTitle];
return string.IsNullOrWhiteSpace(navigationTitle) ? GetTitle(item) : PlainText(navigationTitle);
}
private static bool HasLayout(Item item)
{
return !string.IsNullOrEmpty(item[FieldIDs.LayoutField])
|| !string.IsNullOrEmpty(item[FieldIDs.FinalLayoutField]);
}
private static string PlainText(string value)
{
return string.IsNullOrWhiteSpace(value) ? null : StringUtil.RemoveTags(value).Trim();
}
private static List<string> SplitLines(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<string>();
}
return value
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.Where(line => line.Length > 0)
.ToList();
}
private static string EnsureTrailingSlash(string url)
{
return url.EndsWith("/", StringComparison.Ordinal) ? url : url + "/";
}
private static void AddIfPresent(JObject target, string key, string value)
{
if (!string.IsNullOrWhiteSpace(value))
{
target[key] = value.Trim();
}
}
}
}
A couple of things worth calling out in the builder. The breadcrumbs come straight from the content tree, walking the page’s ancestors and keeping only the items under the site root that have a layout. The dateModified value falls back to the publish date rather than Sitecore’s __Updated field, since __Updated changes on every save (even ones that don’t touch visible content) and we only want to claim dates the page actually displays. And any JSON in the override field gets appended to the graph rather than replacing it.
The Controller Rendering (XP/XM)
With the builder in place, the controller is deliberately thin. It grabs the context page and site root, hands them to the builder, and writes the result into the response:
// src/Feature/StructuredData/code/Controllers/StructuredDataController.cs
// Controller rendering that writes the page's JSON-LD into the server HTML.
using System.Web.Mvc;
using Sitecore.Mvc.Presentation;
namespace Example.Feature.StructuredData.Controllers
{
public class StructuredDataController : Controller
{
public ActionResult JsonLd()
{
var page = PageContext.Current?.Item;
var site = Sitecore.Context.Site;
if (page == null || site == null)
{
return new EmptyResult();
}
var siteRoot = page.Database.GetItem(site.StartPath);
if (siteRoot == null)
{
return new EmptyResult();
}
var json = new SchemaGraphBuilder().Build(page, siteRoot);
if (string.IsNullOrEmpty(json))
{
return new EmptyResult();
}
return Content("<script type=\"application/ld+json\">" + json + "</script>", "text/html");
}
}
}
Register this as a controller rendering in Sitecore (controller StructuredData, action JsonLd), add it to the head placeholder through your standard values, and enable caching with Vary By Data so each page caches its own output. Since the HTML cache clears on publish, the markup stays in sync with the content.
When you need to add FAQPage, Event or LocalBusiness later on, it’s one template ID, one Build method and one branch in the builder. That’s the real payoff… new page types get structured data through a code review rather than through editors hand-writing JSON.
The Headless Version (SitecoreAI / Next.js)
On headless Sitecore, the JSON-LD is built in the Next.js app from the Layout Service route data, and Next.js server rendering puts it into the HTML. As a quick note, Sitecore has renamed XM Cloud to SitecoreAI, and the Content SDK has replaced JSS as the recommended Next.js toolkit. The code below works with either, as it only depends on the shape of the layout data rather than the SDK types.
IMPORTANT: Never add the script in a useEffect, a client component, or next/script with a lazy strategy. Each of those puts the markup right back on the client, which is exactly the problem we are trying to solve. A plain <script> element rendered by a server component (App Router) or during SSR/SSG (Pages Router) lands in the HTML.
First, create a config file that holds the Organization settings and template mapping for each of your sites, keyed by the Sitecore site name:
// src/lib/structured-data/site-schema.config.ts
// Per-site Organization settings and template mapping, keyed by Sitecore site name.
export interface SiteSchemaConfig {
siteUrl: string;
organizationName: string;
logoUrl: string;
sameAs: string[];
articleTemplates: string[];
productTemplates: string[];
}
export const siteSchemaConfig: Record<string, SiteSchemaConfig> = {
'example-site': {
siteUrl: 'https://www.example.com',
organizationName: 'Example Corp',
logoUrl: 'https://www.example.com/images/logo.png',
sameAs: [
'https://www.linkedin.com/company/example-corp',
'https://www.crunchbase.com/organization/example-corp',
],
articleTemplates: ['Article Page', 'Blog Post'],
productTemplates: ['Product Page'],
},
};
Take special note of the articleTemplates and productTemplates arrays. Headless route data only exposes the concrete template name, not the inheritance chain, so you need to list every concrete page template here.
Next, create the graph builder. This is the TypeScript equivalent of the XP builder above, reading from the route fields instead of Sitecore items:
// src/lib/structured-data/build-graph.ts
// Builds a connected JSON-LD @graph from Sitecore Layout Service route data.
// Uses minimal local types so it works with both the Content SDK and JSS.
import { siteSchemaConfig, type SiteSchemaConfig } from './site-schema.config';
type JsonNode = Record<string, unknown>;
interface FieldValue<T> {
value?: T;
}
interface ImageValue {
src?: string;
alt?: string;
}
interface ReferencedItem {
id?: string;
url?: string;
name?: string;
fields?: Record<string, FieldValue<unknown> | undefined>;
}
export interface RouteLike {
name?: string;
displayName?: string;
templateName?: string;
itemLanguage?: string;
fields?: Record<string, unknown>;
}
export interface LayoutDataLike {
sitecore: {
context: {
itemPath?: string | null;
language?: string;
site?: { name?: string };
};
route: RouteLike | null;
};
}
export interface BreadcrumbEntry {
name: string;
path: string;
}
function text(fields: Record<string, unknown> | undefined, name: string): string | undefined {
const field = fields?.[name] as FieldValue<unknown> | undefined;
const value = field?.value;
if (value === undefined || value === null) return undefined;
const plain = String(value).replace(/<[^>]*>/g, '').trim();
return plain.length > 0 ? plain : undefined;
}
function bool(fields: Record<string, unknown> | undefined, name: string): boolean {
const field = fields?.[name] as FieldValue<unknown> | undefined;
return field?.value === true || field?.value === '1';
}
function lines(value: string | undefined): string[] {
if (!value) return [];
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function absolute(siteUrl: string, url: string | undefined): string | undefined {
if (!url) return undefined;
if (/^https?:\/\//i.test(url)) return url;
return `${siteUrl.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`;
}
function imageUrl(
fields: Record<string, unknown> | undefined,
name: string,
siteUrl: string
): string | undefined {
const field = fields?.[name] as FieldValue<ImageValue> | undefined;
return absolute(siteUrl, field?.value?.src);
}
function isoDate(fields: Record<string, unknown> | undefined, name: string): string | undefined {
const raw = text(fields, name);
if (!raw || raw.startsWith('0001-01-01')) return undefined;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}
function withoutEmpty(node: JsonNode): JsonNode {
return Object.fromEntries(
Object.entries(node).filter(([, value]) => value !== undefined && value !== null && value !== '')
);
}
function buildOrganization(config: SiteSchemaConfig, ids: Record<string, string>): JsonNode {
return withoutEmpty({
'@type': 'Organization',
'@id': ids.organization,
name: config.organizationName,
url: `${config.siteUrl}/`,
logo: config.logoUrl ? { '@type': 'ImageObject', url: config.logoUrl } : undefined,
sameAs: config.sameAs.length > 0 ? config.sameAs : undefined,
});
}
function buildArticle(
route: RouteLike,
config: SiteSchemaConfig,
pageUrl: string,
ids: Record<string, string>
): JsonNode {
const fields = route.fields;
const published = isoDate(fields, 'Publish Date');
const authorItem = fields?.['Author'] as ReferencedItem | undefined;
let author: JsonNode | undefined;
if (authorItem?.fields) {
const profileUrl = text(authorItem.fields, 'Profile URL');
const sameAs = lines(text(authorItem.fields, 'Same As'));
author = withoutEmpty({
'@type': 'Person',
'@id': profileUrl ? `${profileUrl}#person` : undefined,
name: text(authorItem.fields, 'Name'),
url: profileUrl,
sameAs: sameAs.length > 0 ? sameAs : undefined,
});
}
return withoutEmpty({
'@type': 'BlogPosting',
'@id': `${pageUrl}#article`,
headline: text(fields, 'Title') ?? route.displayName ?? route.name,
description: text(fields, 'Summary'),
image: imageUrl(fields, 'Image', config.siteUrl),
datePublished: published,
dateModified: isoDate(fields, 'Updated Date') ?? published,
mainEntityOfPage: { '@id': ids.webPage },
publisher: { '@id': ids.organization },
author,
});
}
function buildProduct(route: RouteLike, config: SiteSchemaConfig, pageUrl: string, ids: Record<string, string>): JsonNode {
const fields = route.fields;
const price = text(fields, 'Price');
const currency = text(fields, 'Currency');
const availability = text(fields, 'Availability');
const brand = text(fields, 'Brand');
return withoutEmpty({
'@type': 'Product',
'@id': `${pageUrl}#product`,
name: text(fields, 'Product Name'),
description: text(fields, 'Summary'),
image: imageUrl(fields, 'Image', config.siteUrl),
sku: text(fields, 'SKU'),
brand: brand ? { '@type': 'Brand', name: brand } : undefined,
mainEntityOfPage: { '@id': ids.webPage },
offers:
price && currency
? withoutEmpty({
'@type': 'Offer',
url: pageUrl,
price,
priceCurrency: currency,
availability: availability ? `https://schema.org/${availability}` : undefined,
})
: undefined,
});
}
function parseOverrides(raw: string | undefined, siteName: string): JsonNode[] {
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
const nodes: unknown[] = Array.isArray(parsed)
? parsed
: parsed && typeof parsed === 'object' && Array.isArray((parsed as JsonNode)['@graph'])
? ((parsed as JsonNode)['@graph'] as unknown[])
: [parsed];
return nodes
.filter((node): node is JsonNode => !!node && typeof node === 'object' && !Array.isArray(node))
.map((node) => {
const rest: JsonNode = { ...node };
delete rest['@context'];
return rest;
});
} catch (error) {
console.warn(`[structured-data] Invalid Structured Data JSON on site ${siteName}:`, error);
return [];
}
}
/**
* Returns serialized JSON-LD that is safe to place inside a script tag,
* or null when the page opts out or the site has no configuration.
*/
export function buildStructuredData(layoutData: LayoutDataLike, breadcrumbs: BreadcrumbEntry[] = []): string | null {
const { context, route } = layoutData.sitecore;
const siteName = context.site?.name ?? '';
const config = siteSchemaConfig[siteName];
if (!route || !config) return null;
const fields = route.fields;
if (bool(fields, 'Exclude Structured Data')) return null;
const siteUrl = config.siteUrl.replace(/\/$/, '');
const path = context.itemPath && context.itemPath !== '/' ? context.itemPath : '';
const pageUrl = path ? `${siteUrl}${path}` : `${siteUrl}/`;
const ids = {
organization: `${siteUrl}/#organization`,
website: `${siteUrl}/#website`,
webPage: `${pageUrl}#webpage`,
breadcrumb: `${pageUrl}#breadcrumb`,
};
const resolvedConfig = { ...config, siteUrl };
const pageName = text(fields, 'Title') ?? route.displayName ?? route.name ?? '';
const graph: JsonNode[] = [
buildOrganization(resolvedConfig, ids),
{
'@type': 'WebSite',
'@id': ids.website,
name: config.organizationName,
url: `${siteUrl}/`,
publisher: { '@id': ids.organization },
},
withoutEmpty({
'@type': 'WebPage',
'@id': ids.webPage,
url: pageUrl,
name: pageName,
description: text(fields, 'Summary'),
inLanguage: route.itemLanguage ?? context.language,
isPartOf: { '@id': ids.website },
breadcrumb: breadcrumbs.length > 0 ? { '@id': ids.breadcrumb } : undefined,
}),
];
if (breadcrumbs.length > 0) {
graph.push({
'@type': 'BreadcrumbList',
'@id': ids.breadcrumb,
itemListElement: breadcrumbs.map((crumb, index) => ({
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: absolute(siteUrl, crumb.path),
})),
});
}
const templateName = route.templateName ?? '';
if (config.articleTemplates.includes(templateName)) {
graph.push(buildArticle(route, resolvedConfig, pageUrl, ids));
} else if (config.productTemplates.includes(templateName)) {
graph.push(buildProduct(route, resolvedConfig, pageUrl, ids));
}
// Read the override raw: text() strips tags, which would corrupt JSON containing "<".
const overrideField = fields?.['Structured Data JSON'] as FieldValue<string> | undefined;
graph.push(...parseOverrides(overrideField?.value?.trim(), siteName));
// Escape "<" so no field value can close the surrounding script tag.
return JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/</g, '\\u003c');
}
Finally, create the component that renders it. Note there is no 'use client' and no hooks here, as this must render on the server:
// src/components/structured-data/JsonLd.tsx
// Renders the page's JSON-LD into the server HTML.
// No 'use client', no hooks: this must render on the server.
import {
buildStructuredData,
type BreadcrumbEntry,
type LayoutDataLike,
} from '../../lib/structured-data/build-graph';
interface JsonLdProps {
layoutData: LayoutDataLike;
breadcrumbs?: BreadcrumbEntry[];
}
export default function JsonLd({ layoutData, breadcrumbs = [] }: JsonLdProps) {
const json = buildStructuredData(layoutData, breadcrumbs);
if (!json) return null;
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />;
}
To wire it in, render <JsonLd layoutData={layoutData} /> once in your app’s Layout.tsx. The starter layouts differ between JSS, Content SDK Pages Router and Content SDK App Router, so match whatever variable your layout already uses for the layout data (it works in either the head or the body).
One last thing on headless… the Layout Service route does not include ancestors, so there is no content tree to walk for breadcrumbs. The best approach is to pass the same items your visible breadcrumb component renders (usually from its GraphQL datasource) as the breadcrumbs prop, which keeps the markup and the visible trail identical. If you don’t pass any, the builder simply leaves out the BreadcrumbList. Also keep in mind that with static generation or ISR, the markup only changes when the page regenerates, so make sure your publish-triggered revalidation covers these pages.
Verifying What the Crawlers Actually See
After all that work, you need to confirm the markup is actually in the raw HTML, which is not something browser dev tools will tell you. The following script fetches each page the way a non-rendering crawler would, extracts every JSON-LD block, confirms it parses and lists the types it found. Drop this into your deployment pipeline and running it against a preview environment:
#!/usr/bin/env bash
# check-jsonld.sh
# Verifies that JSON-LD is present and valid in the raw server HTML (no JavaScript executed).
# Usage: ./check-jsonld.sh https://www.example.com/insights/json-ld-in-sitecore [more urls...]
# Exit code 0 = all pages passed, 1 = at least one page failed.
set -euo pipefail
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <url> [url...]" >&2
exit 2
fi
USER_AGENT="Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)"
TMP_FILE="$(mktemp)"
trap 'rm -f "$TMP_FILE"' EXIT
OVERALL=0
for URL in "$@"; do
echo "== $URL"
if ! curl -fsSL --max-time 30 -A "$USER_AGENT" "$URL" -o "$TMP_FILE"; then
echo "FAIL: could not fetch page"
OVERALL=1
continue
fi
if ! python3 - "$TMP_FILE" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
html = handle.read()
pattern = r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>'
blocks = re.findall(pattern, html, re.S | re.I)
if not blocks:
print("FAIL: no JSON-LD found in the server HTML")
sys.exit(1)
ok = True
for index, block in enumerate(blocks, start=1):
try:
data = json.loads(block)
except json.JSONDecodeError as error:
print(f"FAIL: block {index} is not valid JSON ({error})")
ok = False
continue
if isinstance(data, dict):
nodes = data.get("@graph", [data])
elif isinstance(data, list):
nodes = data
else:
nodes = []
types = [node.get("@type") for node in nodes if isinstance(node, dict)]
print(f"OK: block {index} contains {types}")
sys.exit(0 if ok else 1)
PY
then
OVERALL=1
fi
done
exit "$OVERALL"
If your CDN or firewall blocks the script because of the crawler user agent, that is worth knowing too… it means the real AI crawlers are being blocked as well.
Once the raw HTML checks out, run your key templates through Google’s Rich Results Test for Google eligibility and the Schema Markup Validator to validate the schema.org vocabulary as a whole (including types Google ignores). After each release, keep an eye on the enhancement reports in Google Search Console for any new errors.
What It Really Takes
Bottom line, the code is the small part. Hard-coding Organization and WebSite takes about half a day, and the manual JSON field with validation is a day or two. Building the dynamic builder for your first two page types runs somewhere in the range of 5 to 10 developer days for a team that knows its Sitecore solution, with each additional page type adding a day or two after that.
The real cost is the content itself… author items that don’t exist yet, publish dates that were never filled in, and product data that isn’t visible on the page. That remediation is very often the longest task on the list, so plan for it up front.
Governance is where most of these implementations fall apart six months in. Pair an architect who owns the mapping code with the SEO lead who owns which types and properties matter, keep a simple registry of template-to-schema mappings in your solution docs, and keep an eye on how many pages are using the override field. If that number keeps growing, your dynamic mapping is missing something.
Conclusion
With the steps above, you should be able to get JSON-LD into the server HTML of your Sitecore pages, whether you are on XP/XM or headless SitecoreAI. Start by confirming your content is server-rendered, pull any schema out of Google Tag Manager, hard-code your Organization and WebSite entities this week, and then build out the dynamic graph for your two most important page types.
The core aspects to keep in mind are mapping your templates to schema types, reading only from fields the page actually displays, linking everything together in a single graph, and verifying against the raw HTML rather than what your browser shows you. Manual JSON fields are a fine stopgap and override… just don’t let them become the strategy.
