
# JSON schema for a page

> Build reusable page-level JSON-LD in Cascade CMS using Velocity, with region vs head-format patterns, #import usage, and metadata-first fallback/null-safe logic.

URL: https://www.hannonhill.com/cascadecms/latest/articles/developing-in-cascade/script-formats/json-schema-for-a-page.html
category: Developing in Cascade
article-type: reference
content-kind: article
release-availability: all
tags: velocity, json
updated: 2026-10-06

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.

```html
<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.

```bash
## 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 xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:output indent="yes" method="xml"/>

<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](https://www.hannonhill.com/cascadecms/latest/articles/content-authoring/blocks/create-calling-page-index-block.html). 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.

```bash
## 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 |

```bash
## 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.

```bash
## _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 xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:output indent="yes" method="xml"/>

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