JSON Schema for a Page in Cascade CMS
Overview
If you need structured data in Cascade, the most common request is page-level JSON-LD in the head. This article focuses on that use case and gives you two implementation patterns: attach a dedicated region format or import a shared JSON-LD fragment from your existing head format. Both work. The right choice depends on how reusable your setup needs to be.
application/ld+json), not JSON Schema validation documents used for API/data validation.Quick decision guide
| If your priority is | Use this pattern | Why |
|---|---|---|
| Simple setup on one template/output | Dedicated region + attached format | Everything is local to one output configuration. |
| Reuse across many pages/templates | Shared head fragment with #import |
One format can be maintained and reused in multiple head formats. |
| Avoiding repeated logic drift | Shared head fragment with #import |
Fallback/null handling and field naming stay consistent. |
Option A: add a dedicated region and attach a format
This is the most direct route when you only need schema in one template/output.
Setting up the region
Add a region to your template
In your template's HTML, add a system region tag inside <head> where you want the JSON-LD to render.
<head>
<title>...</title>
<system-region name="JSON_SCHEMA"/>
</head>
Create a Velocity format
Create a new Script Format that renders the JSON-LD block. The code samples in this article can be used directly.
Attach the format to the region
In your Configuration Set, open the output and assign the format to the JSON_SCHEMA region you created.
## Region-attached format sample
#set ($js_title = "")
#set ($js_title = $currentPage.metadata.title)
#set ($js_desc = "")
#set ($js_desc = $currentPage.metadata.description)
#if ($_PropertyTool.isNotEmpty($js_title))
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "$_EscapeTool.xml($js_title)"#if ($_PropertyTool.isNotEmpty($js_desc)),
"description": "$_EscapeTool.xml($js_desc)"#end
}
</script>
#end
## Region-attached format sample (XPath)
#set($pageNode = $_XPathTool.selectSingleNode($contentRoot, "/system-index-block/calling-page/system-page"))
#set ($js_title = "")
#set ($js_desc = "")
#set ($js_titleChild = $pageNode.getChild("title"))
#if ($_PropertyTool.isNotEmpty($js_titleChild))
#set ($js_title = $js_titleChild.value)
#end
#set ($js_descChild = $pageNode.getChild("summary"))
#if ($_PropertyTool.isNotEmpty($js_descChild))
#set ($js_desc = $js_descChild.value)
#end
#if ($_PropertyTool.isNotEmpty($js_title))
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "$_EscapeTool.xml($js_title)"#if ($_PropertyTool.isNotEmpty($js_desc)),
"description": "$_EscapeTool.xml($js_desc)"#end
}
</script>
#end
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/">
<xsl:variable name="page" select="system-index-block/calling-page/system-page"/>
<xsl:variable name="title" select="$page/title"/>
<xsl:variable name="desc" select="$page/summary"/>
<xsl:if test="normalize-space($title) != ''">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "<xsl:value-of select="$title"/>"<xsl:if test="normalize-space($desc) != ''">,
"description": "<xsl:value-of select="$desc"/>"</xsl:if>
}
</script>
</xsl:if>
</xsl:template>
</xsl:stylesheet>
Option B: import a shared schema format from your head format
If you're working across more than one template, this is the right call. One shared format gets imported wherever you need it.
## Existing head format
<head>
<title>$currentPage.metadata.title</title>
#import ("_cms/formats/shared/schema/page-jsonld")
</head>
## Existing head format (XPath)
#set($pageNode = $_XPathTool.selectSingleNode($contentRoot, "/system-index-block/calling-page/system-page"))
<head>
<title>$!{pageNode.getChild("title").value}</title>
#import ("_cms/formats/shared/schema/page-jsonld")
</head>
In the imported format, use a variable prefix (for example js_) to avoid collisions with variables in the calling head format.
#import requires a database round trip. Keep the number of calls low and consolidate shared schema logic into one format when possible.Null checks that prevent bad output
JSON-LD should be omitted when required data is missing, not rendered with empty or stale values. The safest pattern is reset-before-set plus explicit property checks.
| Field | Source | If empty |
|---|---|---|
| Title | $currentPage.metadata.title, then $currentPage.label |
Do not emit JSON-LD block |
| Description | $currentPage.metadata.description |
Omit description property |
| Type | Derived from $currentPage.contentType.name |
Default to WebPage |
Mapping content types to schema.org types
Instead of hardcoding WebPage, you can derive the @type from the page's content type in Cascade. Add an #if/#elseif chain that maps your content type names to schema.org types. Adjust the names on the left to match the content types defined in your site.
| Schema.org type | Typical use |
|---|---|
WebPage |
Default for general pages |
Article |
General articles, blog posts |
NewsArticle |
News stories, press releases |
Event |
Events with dates and locations |
FAQPage |
Frequently asked questions pages |
Person |
Staff bios, faculty profiles |
AboutPage |
About us / mission pages |
CollectionPage |
Index or listing pages |
## Map content type to schema.org type
#set ($js_type = "WebPage")
#set ($ctName = $currentPage.contentType.name)
#if ($ctName == "News Article")
#set ($js_type = "NewsArticle")
#elseif ($ctName == "Event")
#set ($js_type = "Event")
#elseif ($ctName == "FAQ")
#set ($js_type = "FAQPage")
#elseif ($ctName == "Staff Bio")
#set ($js_type = "Person")
#end
<!-- Content type name is not available in the index block XML.
Hardcode the type or use a Velocity format instead. -->
<xsl:variable name="schemaType" select="'WebPage'"/>
Reusable snippet: complete format
The following format can be imported from any head format. It uses built-in metadata, null-safe checks, content-type-based @type mapping, and conditional property output.
## _cms/formats/shared/schema/page-jsonld
#set ($js_title = "")
#set ($js_description = "")
#set ($js_type = "WebPage")
## Title: metadata title -> label (display name / title / system name)
#set ($js_title = $currentPage.metadata.title)
#if ($_PropertyTool.isEmpty($js_title))
#set ($js_title = "")
#set ($js_title = $currentPage.label)
#end
## Description from built-in metadata
#set ($js_description = "")
#set ($js_description = $currentPage.metadata.description)
## Type from content type name
#set ($ctName = $currentPage.contentType.name)
#if ($ctName == "News Article")
#set ($js_type = "NewsArticle")
#elseif ($ctName == "Event")
#set ($js_type = "Event")
#elseif ($ctName == "FAQ")
#set ($js_type = "FAQPage")
#elseif ($ctName == "Staff Bio")
#set ($js_type = "Person")
#end
## Guard clause: no title means no JSON-LD block
#if ($_PropertyTool.isNotEmpty($js_title))
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "$_EscapeTool.xml($js_type)",
"name": "$_EscapeTool.xml($js_title)"#if ($_PropertyTool.isNotEmpty($js_description)),
"description": "$_EscapeTool.xml($js_description)"#end
}
</script>
#end
## _cms/formats/shared/schema/page-jsonld (XPath)
#set($pageNode = $_XPathTool.selectSingleNode($contentRoot, "/system-index-block/calling-page/system-page"))
#set ($x_title = "")
#set ($x_description = "")
#set ($x_type = "WebPage")
## Title: title node -> display-name -> name (system name)
#set ($x_titleChild = $pageNode.getChild("title"))
#if ($_PropertyTool.isNotEmpty($x_titleChild))
#set ($x_title = $x_titleChild.value)
#end
#if ($_PropertyTool.isEmpty($x_title))
#set ($x_title = "")
#set ($x_dnChild = $pageNode.getChild("display-name"))
#if ($_PropertyTool.isNotEmpty($x_dnChild))
#set ($x_title = $x_dnChild.value)
#end
#end
#if ($_PropertyTool.isEmpty($x_title))
#set ($x_title = "")
#set ($x_nameChild = $pageNode.getChild("name"))
#if ($_PropertyTool.isNotEmpty($x_nameChild))
#set ($x_title = $x_nameChild.value)
#end
#end
## Description from XML node
#set ($x_descChild = $pageNode.getChild("summary"))
#if ($_PropertyTool.isNotEmpty($x_descChild))
#set ($x_description = $x_descChild.value)
#end
## Content type name is not in the index block XML
## Use $currentPage.contentType.name in a Velocity format for type mapping
#if ($_PropertyTool.isNotEmpty($x_title))
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "$_EscapeTool.xml($x_type)",
"name": "$_EscapeTool.xml($x_title)"#if ($_PropertyTool.isNotEmpty($x_description)),
"description": "$_EscapeTool.xml($x_description)"#end
}
</script>
#end
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/">
<xsl:variable name="page" select="system-index-block/calling-page/system-page"/>
<!-- Title: title -> display-name -> name (system name) -->
<xsl:variable name="title">
<xsl:choose>
<xsl:when test="normalize-space($page/title) != ''">
<xsl:value-of select="$page/title"/>
</xsl:when>
<xsl:when test="normalize-space($page/display-name) != ''">
<xsl:value-of select="$page/display-name"/>
</xsl:when>
<xsl:otherwise>
<xsl:value-of select="$page/name"/>
</xsl:otherwise>
</xsl:choose>
</xsl:variable>
<xsl:variable name="description" select="$page/summary"/>
<!-- Content type name is not in the index block XML — hardcode or use Velocity -->
<xsl:variable name="schemaType" select="'WebPage'"/>
<!-- Guard: no title means no JSON-LD block -->
<xsl:if test="normalize-space($title) != ''">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "<xsl:value-of select="$schemaType"/>",
"name": "<xsl:value-of select="$title"/>"<xsl:if test="normalize-space($description) != ''">,
"description": "<xsl:value-of select="$description"/>"</xsl:if>
}
</script>
</xsl:if>
</xsl:template>
</xsl:stylesheet>
Because this is imported, any page can define different values through metadata and get output tailored to that page without duplicating format logic.
Is this enough for most pages?
For getting started, yes. A clean JSON-LD block with title, description, and a content-type-derived @type is enough for many standard content pages.
Pages often need more over time when they represent specific content types. Common additions include date fields for Event, author for Article, organization/sitewide schema, or breadcrumb schema. Those can be layered on once your baseline output is stable.
Start here: one shared format with null guards. Add fields and schema types as page type and SEO requirements justify it.