How to use schema-based structured content in AEM Edge Delivery Services

Overview

Structured Content lets authors fill a form instead of a free document. You define a schema, the form is generated from it, and each save produces a record. A list view, a detail page, and consumers outside Edge Delivery all read that one record.

Structured Content adds form-based, schema-constrained authoring to Document Authoring (DA). You write a schema, a documented subset of JSON Schema 2020-12. Authors fill the form the schema generates. Each save produces a record.

A record is a DA document, so it is a page at its own URL. The markup is a .da-form metadata block, then a sibling block named after the schema, one row per field. It is not a designed page. Rows print raw values, an image field prints its URL as text, and every record gets the same <title>.

Three readers work off that one save:

Nothing is re-authored.

The worked example is a bike product catalog. One Product schema governs a /products folder. Each document in that folder opens in the generated form and saves as a product record.

Before you start

The Challenge

Config-style content is in spreadsheets or free-form documents today: product catalogs, spec sheets, team directories. The usual options do not give you records you can rely on.

You want a record authored against a schema the form enforces. Required fields are filled, categories come from a fixed list, numbers are bounded, and nested lists work.

The Pattern

Preview renders the record as a page at the aem.page URL. Publishing serves that page from aem.live and your production domain, and adds it to the query index.

     Schema Editor  -- defines -->  schema                            
                                      |                               
                                      v   editor.path routes /products
                               DA form editor                         
                                      |                               
                                      v   fill, save, preview, publish
                      one record = one published page                 
                         /products/apex-road-racer                    
                                      |                               
               +----------------------+----------------------+        
               v                      v                      v        
         query index             page markup           da-sc worker   
          list view              detail view         JSON, outside EDS

What a record page serves

The markup downstream reads, trimmed from /products/apex-road-racer.plain.html:

https://github.com/aem-sandbox/examples/blob/844c2759ce42c7a3f7e39b08ec3d3dee88308380/examples/how-to-use-structured-content/record-page.html

From it:

Use Cases

The example is a bike catalog. The same setup works for any records that share a structure. The Product schema exercises the supported keywords: a regex-patterned slug, a category dropdown, bounded numbers, booleans with defaults, arrays of primitives, and arrays of reusable nested objects.

One authored record, three ways to read it:

JSON2HTML fits here too, but not between the record and its page. See When JSON2HTML fits.

Configuration

Three things are configurable. There is the schema. The editor.path registration routes a folder to the form. The index definition sets what the listing can read.

Schema keywords

The full rules are in schema-spec.md, which is the source of truth.

Structure:

Root type must be object or array. The keys metadata and section-metadata are reserved, and behavior if you use them is undefined.

Annotations and constraints:

Widget mapping:

Objects and arrays are nodes in the editor's navigation tree, not inline sections.

Unsupported keywords

Some unsupported keywords are flagged. Others do nothing at all.

Flagged. These raise a schema issue with a reason code, and the editor marks the schema non-editable. The flagged constructs are oneOf, anyOf, allOf, a type array like ["string","null"], an external $ref, an unresolvable #/ pointer, a missing type, a type outside the six, and an unparseable pattern. The stored value is left untouched, not dropped.

Silent. These have no code path at all: const, additionalProperties, patternProperties, not, format, and any x- custom hint. No format means no native date, email, or uri type, and no date picker. Author a date as a string and constrain the shape with pattern.

A field missing from the form has a named reason in the panel. A constraint that fails and reports no reason is one of the silent keywords.

Types on the wire

The schema binds what the form accepts. It does not bind what comes out.

A record's markup is text. Every primitive is written as a string, so "49" and 49 both render as <li>49</li>. Reading the markup back is a guess: each value is sniffed for a boolean or a number. frameSizes is declared as an array of strings. The delivery worker returns it as [49, 52, 54, 56, 58]. colors comes back as strings. Treat the delivered types as advisory and coerce what you need.

The schema name is the block class

The schema's name is the block class on each record page. A schema named product renders the record as <div class="product">, and that class is what loads blocks/product. Rename the schema and the block stops loading. The page falls back to raw field rows. Array-of-object properties name their own blocks the same way: a specs property emits <div class="specs specs-wuvstu"> siblings.

The editor.path registration

Open the DA config for your org at https://da.live/config, pick the org, and add a row. The key is editor.path. The value is /<ORG>/<SITE>/<FOLDER>=https://da.live/form#, one string, and the trailing # is part of it. This needs the Organization Administrator role.

Each document created in that folder then opens in the Structured Content form editor, not the default DA editor. The editor prompts the author to pick a schema. The path is exact and case-sensitive. Add one row per folder.

The index definition

The index is how a listing block finds the records. Its selectors run against the record's published markup, anchored on each field's heading id.

https://github.com/aem-sandbox/examples/blob/844c2759ce42c7a3f7e39b08ec3d3dee88308380/examples/how-to-use-structured-content/query.yaml

Two things about those selectors.

The id is the property key lowercased, not the key as written. weightKg renders as <h3 id="weightkg">, inStock as instock. CSS id selectors are case-sensitive, so #weightKg matches nothing and the column comes back empty with no error.

Scope every selector to the schema block. div:has(#name) matches every ancestor that contains #name, which includes the section div and .product. A select: property takes all matches and joins them. An unscoped selector returns a concatenation, not the first hit. selectFirst: is the single-match alternative.

The index also has a row for the /products page itself, with every column empty. Filter rows on a required column in the block.

A robots column is optional and reads the page head, not the record's markup. Add it to keep noindex records out of sitemap.xml, which only skips them when the index has that column.

On a DA-backed site this config belongs in the AEM Config Service (repoless), not in a repo file. Apply the YAML with the Admin API:

curl -X POST https://admin.hlx.page/config/<ORG>/sites/<SITE>/content/query.yaml \
  -H 'authorization: token <admin-api-key>' \
  -H 'content-type: text/yaml' \
  --data-binary @query.yaml

The Index Admin tool does the same from a UI, and has a Reindex button. The output is served at https://main--<SITE>--<ORG>.aem.live/products-index.json.

Pages are indexed when they are published. After you apply a definition, re-publish the records you already have, or reindex, or the index is empty.

Implementation

The build takes six steps, two of them privileged.

Both blocks import createTag and QUERY_INDEX_PAGE_SIZE from scripts/shared.js, a helper file in the examples repo rather than part of Edge Delivery. createTag(tag, attributes, content) returns an element and skips null attributes and content. QUERY_INDEX_PAGE_SIZE is 500. On your own site, copy that file or swap the calls for document.createElement.

1. Author the schema

Create the schema in the Schema Editor, New Schema. Schemas are stored as documents at /<ORG>/<SITE>/.da/forms/schemas/<name>. They are visible only through the Schema Editor app, so do not hand-edit them in the normal DA browser. The Product schema covers the supported keywords, with $defs reuse.

Name the schema product, because that name is the block class the detail block reads.

image is a plain string URL. The form editor has no asset picker yet, so the author pastes a URL. That URL prints as visible text on the record page until a block renders it. sku is readOnly, so the form cannot fill it: give it a default or write the value in out of band.

2. Route the folder to the form editor

Add the editor.path entry from Configuration for /products. For the example site the value is /aem-sandbox/examples/products=https://da.live/form#. Each document under /products now opens in the form and prompts for the Product schema.

3. Index the folder

POST the index definition from Configuration to the config service, or apply it with the Index Admin tool.

Publish the records, then the listing reads https://main--examples--aem-sandbox.aem.live/products-index.json, one row per published record.

4. Build the listing block

blocks/products fetches the index and renders cards. It pages through the index eagerly, because the category filter counts need catalog totals rather than one page. It also builds the filter chips, a sort select, an aria-live status line, and a Load More button.

Eager pagination for category counts: Fetches full index to calculate filter chip totals

Rows are filtered before they reach the grid. That drops the /products row, the one with an empty name.

Filtering empty folder rows: Drops index row for /products page itself

Each field read is guarded, because empty fields are absent from the index. The card is one anchor around the whole tile, and its href is the index path column. The index path is not a block option: there is one product index.

5. Author the listing page

Create /products in DA with the normal editor. The folder page itself is not routed to the form: editor.path applies to documents inside the folder.

Add a block table. The header cell names the block, and the rows below it are config:

+----------+-----------+
| Products             |
+----------+-----------+
| limit    | 12        |
+----------+-----------+
| sort     | price-asc |
+----------+-----------+

Each row is optional. limit defaults to 24, sort to name, and categories to the categories in the index. Preview and publish the page.

6. Build the detail block

The detail page already exists. Publishing a record puts a page at /products/<slug> with every field in the markup. The detail view is blocks/product, which reads what is there and rebuilds it. It fetches nothing.

Parsing record markup: Reads structured content field blocks into plain objects, handling arrays and self:// refs

Following self:// pointers: Resolves refs to sibling blocks by class name

Three things to handle, all in What a record page serves:

The record is the product page. It is addressable and cacheable like the site's other pages, and its HTML already has every field.

One gap to close before you treat these as landing pages. The served HTML has <title>x-schema-name</title>, the same string in og:title, no meta description, and no <h1>. The pipeline takes the title from the document's first heading, and that heading is in the .da-form block. The block sets document.title from the product name, which fixes the browser tab and any client that runs JS. It cannot fix the served metadata, so a social scraper still reads x-schema-name. For that, add a bulk /metadata sheet keyed by URL, or a Metadata block per document.

When JSON2HTML fits

JSON2HTML generates Edge Delivery pages from a JSON feed through a Mustache template. It fits data you do not author as pages: one page per feed row. Structured Content records are already pages, so inside Edge Delivery the detail view does not need it.

The combination does come up: records as JSON for non-AEM apps, and generated pages from the same data. That works, with one constraint.

Do not point a JSON2HTML rule at the delivery worker. The rule config accepts it and nothing warns you. json2html.adobeaem.workers.dev cannot fetch da-sc.adobeaem.workers.dev. A Worker cannot fetch another Worker on the same zone unless the global_fetch_strictly_public compatibility flag is set (Cloudflare error 1042). The generated page 404s, and the worker's x-error response header names the failed endpoint fetch.

If the JSON comes from *.adobeaem.workers.dev, JSON2HTML cannot read it today. Re-serve the records from your own domain, through a handler in a Worker you already run, and point the rule at that. The shipped JSON2HTML examples all source their feeds from somewhere else.

Mount the generated pages on a path of their own. /products/<slug> is taken by the record page.

Usage

The delivery endpoint

The delivery worker converts a record's published HTML into JSON. It is most commonly used to consume the structured content from outside EDS.

https://da-sc.adobeaem.workers.dev/<preview|review|live>/<ORG>/<SITE>/<PATH>

A trailing .json is accepted, and any other extension 404s. The endpoint URL may change (Adobe reserves this), so wrap it in one place rather than scattering it. Authenticated sites pass authorization: token hlx_.... The worker sends Access-Control-Allow-Origin: *, so browser code can call it from any origin.

const res = await fetch(
  'https://da-sc.adobeaem.workers.dev/live/aem-sandbox/examples/products/apex-road-racer',
);
const { data } = await res.json();   // fields are under data, not at the root

The response is a nested { metadata, data } object, with empty fields stripped from data. Read product.data.name, not product.name. A full response for one record:

https://github.com/aem-sandbox/examples/blob/844c2759ce42c7a3f7e39b08ec3d3dee88308380/examples/how-to-use-structured-content/delivery-response.json

A record with an unfilled field has that key absent from data rather than empty. Only preview and review are recognized as tiers. Anything else, including Preview, falls through to live content with a 200. The worker reads the main branch, so delivery is not branch-scoped, unlike JSON2HTML configs.

The docs.da.live guide shows a flat object. The live worker delivers the nested form, so code against data.

Authoring a record

Once the folder is routed and the schema exists, an author opens /products in DA and creates a document. The form editor prompts for the Product schema. They fill the form and it auto-saves. To reach the live endpoint and the index, they preview and then publish. Auto-save alone does not publish, and preview alone does not index.

There is no bulk publish from the form editor's folder view yet, so a catalog is one publish per record.

Keep the authored slug value equal to the document's path segment. Nothing forces them to match, and if they drift, the detail page's h1 id no longer matches the URL serving it.

Creating records in bulk

The form is one record at a time, which does not scale to a catalog import. da-sc-mcp is the recommended path for that today. It is an MCP server that turns JSON into the same record HTML a form save writes. An agent reads your source file and creates the records from it.

Setup is two servers plus the companion skills. da-sc compiles a schema, validates data against it, and serializes a { metadata, data } document to DA HTML. It is stateless and needs no auth. da handles persistence and writes the result to a DA path.

claude mcp add da-sc --scope user --transport http https://da-sc-mcp.adobeaem.workers.dev/mcp
claude mcp add da --scope user --transport http https://mcp.adobeaemcloud.com/adobe/mcp/da
npx skills add "adobe-rnd/da-sc-mcp" --all

Six skills come with it. import-structured-content handles data against a schema that already exists. author-structured-content handles source data with no schema yet. The other four cover schema generation, validation, serialization on its own, and the editor URLs.

The serializer is @adobe/da-sc-sdk, which the form editor and the delivery worker also run on. A generated record has the markup a form save produces. Validation returns errors keyed by JSON Pointer, so a number out of range or a value outside an enum is caught before the write.

It does not publish. The records are written to DA as source, and preview and publish are still per record.

Testing & Troubleshooting

There is no offline simulator for the form. Iterate the schema in the Schema Editor, then author a throwaway document against it to see the generated form. aem-cli serves the blocks against the site's own content, so you can build and style them with no DA access.

Content is per-site and shared across branches. A feature branch tests code against the same content, so a schema or record change on a branch changes the live site. Stage them in a /drafts/ folder excluded from the index, and check the preview endpoint before publishing.

Common failure modes:

Reference