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:
- The query index reads the published markup and returns a few columns per record. That is the list view.
- A block decorates the record's own markup in place. That is the detail view.
- The delivery worker returns the record as JSON at an endpoint. That is for consumers outside Edge Delivery.
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
- Your site's content source is DA. Structured Content does not work on a Google Drive or SharePoint backed site.
- You have the Organization Administrator role on the DA org. The
editor.pathentry cannot be set without it. - You can apply an index definition, with an admin API key for
admin.hlx.pageor access to the Index Admin tool. - You can preview and publish on the site. A record is not in the index or at the
liveendpoint until it is published.
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.
- Free-form documents give no structure guarantee. Every author lays out the specs differently, so there is nothing reliable to parse. A missing field looks the same as a typo.
- A spreadsheet as the data source has no per-field validation and no closed value sets. It has no place for repeating nested records like a list of spec rows or FAQs.
- AEM Content Fragments solve the modeling problem but are heavy for a DA-based site, and add a separate authoring surface.
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
- One save, three readers. The index reads the published markup. A block decorates that same markup. The worker converts it to JSON.
- Empty means gone. An empty string, array, or object is stripped on save. The row disappears from the markup, the index column comes back empty, and the key is absent from the JSON. Every consumer has to tolerate missing fields.
falseis not empty: it renders as the literal textfalse. - The schema binds the form only. A field the schema does not define cannot be authored. Types are looser. The markup is untyped text, and the JSON re-infers types from it. A delivered value can contradict its declared type.
- One schema governs a folder. Many records, one structure, and one place to change it.
What a record page serves
The markup downstream reads, trimmed from /products/apex-road-racer.plain.html:
From it:
.da-formand.productare siblings, not nested. So are the.specsblocks.- The heading id is the property key lowercased.
weightKgisid="weightkg". The heading text keeps the original spelling. - Arrays of objects are not inline. The parent cell has
self://#specs-wuvsturefs, and each ref names a sibling block's class, not an id. - Booleans and numbers are text.
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:
- List view from the query index.
products-index.jsongives a few columns per published record. A block renders the grid and filters client-side. - Detail view from the record's own page.
/products/apex-road-raceralready exists, with every field in its markup. A block decorates it. No fetch, no template. - Headless from the delivery worker. An app, a kiosk, or a non-AEM site GETs the record as JSON and renders 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:
type(every node, required): one ofstring,number,integer,boolean,object,array. Not an array of types. Nothing is inferred frompropertiesorenum.title(every node): the field's label in the form. Nothing enforces it. A node without one is labelled with the raw property key.properties(onobject): the field schemas. Keys start with a letter, then[A-Za-z0-9-].required(onobject): an array of property names that must be filled.items(onarray): a single schema object, not an array, describing each element.$defs/$ref: reusable shapes, same-document pointers only (#/$defs/Name). Keywords on the$refnode override the target, so{ "$ref": "#/$defs/Spec", "title": "Row" }relabels without redefining.required,properties, and$defsmerge with the target rather than replace it.
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:
default(any): pre-fills the field and survives an otherwise empty save. Defaults apply only when the whole document is empty, so adding a defaulted field does not backfill existing records.readOnly(any): a disabled input with the value still shown. The form cannot fill it. AreadOnlyfield is empty unless it has adefaultor the value is written into the document some other way.enum(onstring): a closed set, rendered as a dropdown. On a number, integer, or boolean it is accepted and not checked.minLength/maxLength(onstring): character-count bounds.pattern(onstring): an ECMA-262 regex, unanchored."[a-z0-9-]+"passes onApex Road Racer. Write"^[a-z0-9-]+$".minimum/maximum(onnumber,integer): inclusive bounds.minItems/maxItems(onarray): element-count bounds. An empty array counts as absent, sominItems: 1does not make a field required. Userequiredfor that.
Widget mapping:
string: a text input, or a dropdown withenum.numberandinteger: numeric inputs.boolean: a checkbox defaulting tofalse.array: a repeating add and remove list.readOnly: a disabled input.
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.
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.
- Author the schema in the Schema Editor app.
- Route the folder to the form editor. An org config row, and it needs the Organization Administrator role.
- Index the folder. A config service POST, and it needs an admin API key.
- Build the listing block in the code repository.
- Author the listing page in DA, with the normal editor.
- Build the detail block in the code repository.
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:
- Read the key from the label cell, not the id. Ids are lowercased, so
#weightKgmatches nothing. They are also de-duplicated across the document, so the second.specsblock haslabel-1andvalue-1. - Booleans are the strings
trueandfalse.if (value)is true for both. Compare against the string, or read the JSON endpoint where they are real booleans. - Resolve
self://refs by class.getElementByIdreturns null. Take the token from the ref at runtime rather than hardcoding it.
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:
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:
- A field is missing from the JSON. Empty is stripped on save. Fill the field, or handle the missing key in code.
defaultvalues survive, andfalseis not empty. - A field is missing from the form. Open the editor's schema-issues panel. A flagged construct has its reason listed there, and the stored value is left alone. If nothing is listed, the keyword is one of the silent ones and it did nothing.
- A constraint does nothing. Check it against Unsupported keywords.
enumon a number is accepted and not checked,minItems: 1does not make a field required, and an unanchoredpatternmatches a substring. - The index is empty after applying a definition. Pages are indexed on publish. Re-publish the records, or hit Reindex in the Index Admin tool.
- An index column is empty. The selector's id is the property key lowercased, so
#weightKgmatches nothing. Lowercase it. - An index column has garbage in it. The selector matched more than one element and the indexer joined them. Scope it to the schema block, or switch
select:toselectFirst:. - The listing shows a blank first card. That is the
/productspage's own index row. Filter rows on a required column. - The listing renders nothing. Check that the
/productsdocument has aProductsblock table, and that the page is published. - The detail block reads
undefinedfor a camelCase field. Heading ids are lowercased and de-duplicated. Read the key from the label cell's text. - The detail page shows raw field rows. The block class comes from the schema name. A schema named something other than
productdoes not loadblocks/product. - A social card or a crawler shows
x-schema-name. The block rewritesdocument.titleclient-side, but the served<title>andog:titlestill come from the.da-formheading. Add a Metadata block or a bulk/metadatasheet. - The
liveendpoint404s. The record is saved but not published. Usepreviewto check, and publish to reachlive. - The delivery endpoint returns
500. The path is not a Structured Content record. - The record page fires
404s forda-form,specs, andfaqs. Edge Delivery treats those sibling divs as blocks and requests JS and CSS for each. The detail block removes them, but only after the requests went out. Ship empty stub blocks to silence it. - The form editor will not load for a folder.
editor.pathneeds the Organization Administrator role and an exact, case-sensitive folder path. - The schema is not in the DA browser. Schemas are at
.da/forms/schemasand are visible only through the Schema Editor app. - No image picker, no date picker. Image fields are plain string URLs for now, and print as visible text on the record page. The asset picker, date picker, and rich-text links are not supported yet.
- Page metadata is not in the form editor. No
robots: noindex, canonical, or custom title and description there. Add a Metadata block in the standard DA editor, or a bulk/metadatasheet keyed byURL.robots: noindexkeeps a page out ofsitemap.xmlonly when the index feeding the sitemap has arobotscolumn.
Reference
- Live example: examples.bbird.live/products
- Full source: examples/how-to-use-structured-content (schema, index definition, sample payloads), blocks/products and blocks/product
- DA Structured Content guide: docs.da.live/developers/guides/structured-content
- Schema spec (source of truth): github.com/adobe/da-sc-sdk schema-spec.md
- SDK: github.com/adobe/da-sc-sdk
- Bulk creation: da-sc-mcp and its quick start
- Schema Editor: da.live/apps/schema
- Generating pages from a JSON feed: How to generate dynamic pages from JSON using JSON2HTML
- Indexing: aem.live/developer/indexing and the Index Admin tool
- AEM Config Service (DA): docs.da.live/administrators/reference/aem-config-service
- AEM Edge Delivery documentation: aem.live/docs