Formats

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.

Scope note: This article covers SEO-oriented JSON-LD output (application/ld+json), not JSON Schema validation documents used for API/data validation.

Quick decision guide

Choose the pattern based on where you need the schema logic to live.
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

1

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>
2

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.

3

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>
XPath and XSLT require an index block: The XPath and XSLT tabs assume a calling-page index block is already attached to the region or configuration. If you don't have one set up, see Create a Calling Page Index Block. If you're not already working with index blocks, the Cascade API tab is the simpler starting point.
When this option is a good fit: Use a region-attached format when your schema output truly belongs to a single template/output. If you expect reuse, move to Option B early.

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.

Performance reminder: #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.

Where each JSON-LD value comes from and what happens when it's empty.
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.

Common schema.org types you might map to.
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.