When to Use
Author a Template by API: populate linked_sections for every API-placed section (P27), plus a complete worked Freeform composition body.
Part of the API-authoring set. Start at author-composition-via-api, which routes to this one.
Authoring a Template: populate linked_sections for every API-placed section (P27)
Sibling concern to P22-P25 (which were section-side). When a Template’s ui tree contains section-composition nodes that were placed via API, the template’s linked_sections reference field ALSO has to be populated, even though SSR walks the nodes directly. Same editor-vs-runtime asymmetry as P23 but at the template layer.
The asymmetry
| Path | How it discovers placed sections |
|---|---|
| Editor canvas (template open in Studio web app) | Loads the template with ?include[]=linked_sections (resolves the references inline), builds spec.sectionCompositions from the resolved linked_sections field. If empty, there are no section specs and you get ”Template Did Not Load” |
| SSR / runtime (sdk.fetchCompositionData) | Walks the template’s ui tree looking for section-composition nodes. Reads each node’s metadata.compositionUID. Fetches each section composition separately |
So a template can render perfectly on the deployed site (SSR walks the nodes) yet refuse to open in the editor (linked_sections is empty, editor has no specs to resolve the nodes against). ”Template Did Not Load” in the editor + a passing SSR render = empty linked_sections.
Recipe: populate linked_sections when API-placing sections
When your composition-authoring script writes section-composition nodes into a template’s ui, ALSO populate the template entry’s linked_sections field with a reference per placed section’s composition entry:
// Template composition entry (CMA write)
{
"title": "Product Page",
"url": "/products/{{entry.title}}",
"url_metadata": {
"url_source": "content_type_url_pattern",
"url_queries": "{\"include\":[],\"only\":{},\"where\":{}}"
},
"composable_uid": "product_page_template",
"connected_content_type": "product",
"place_composition_as": "page",
"ui": "zlib:<base64 of the ui tree containing section-composition nodes>",
"linked_sections": [
{
"uid": "<section-A composition entry uid>",
"_content_type_uid": "<compositions-CT-uid>" // the project's compositions CT
},
{
"uid": "<section-B composition entry uid>",
"_content_type_uid": "<compositions-CT-uid>"
}
// …one reference per distinct placed section. Dedupe: a section placed
// multiple times in the same template only needs one entry here.
]
}
The references can be bare {uid, _content_type_uid} stubs. The CMA stores them. The editor’s ?include[]= resolves them inline when loading. There’s no UI-only metadata to mirror (unlike P23’s selectedField).
Then publish the template entry. The editor reads from the published entry. An unpublished update won’t show up.
Pre-flight check for an API-authored template
Before you mark a template as ready, verify both paths agree:
# 1. Editor path — does linked_sections resolve to actual sections? curl -s "https://<cdn-host>/v3/content_types/<compositions-CT-uid>/entries/<template-entry-uid>?include[]=linked_sections" \ -H "api_key: <stack-api-key>" -H "access_token: <delivery-token>" \ | jq '.entry.linked_sections | length, .entry.linked_sections[0].uid' # 2. SSR path — does fetchCompositionData find the same sections? # (Use verify-setup Layer 7's script and dump section_scoped_data; the key count # should equal the count of distinct section-composition nodes in the template's ui.)
Both must agree:
- If the editor path returns 0 sections but the SSR path returns N, linked_sections is not populated. Populate per the recipe above.
- If the editor returns N sections but SSR returns 0, linked_sections is set but the ui tree has no section-composition nodes (probably a separate authoring bug).
- If both return matching N, both paths are wired correctly.
Complete Example: a Real Freeform Composition Body
Every field below is what Studio actually stores for a real Freeform composition. Use this shape verbatim when hand-authoring one. The tree is a single page root holding a doc-hero component, which holds a doc-product-card in the hero’s "Actions" slot, all bound with static_value keys.
The ui tree (inflated, before zlib+base64)
{
"uid": "mZfkYLfEsk28cuV",
"type": "page",
"attrs": {},
"metadata": {},
"styles": {},
"props": {
"children": { "type": "slot", "slot": "lW5KA7MYsHQxdHC" }
},
"slots": {
"lW5KA7MYsHQxdHC": [
{
"uid": "mHjnXsgKbBlj_PZ",
"type": "doc-hero",
"attrs": {},
"metadata": { "slotNames": { "7Opc3nDCi1wwpyy": "Actions" } },
"styles": { "default": { "classes": [""], "responsiveStyles": { "default": {} } } },
"props": {
"headline": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-headline" } },
"subhead": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-subhead" } },
"cover": { "type": "imageurl", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-cover" } },
"ctaLabel": { "type": "string", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-ctaLabel" } },
"ctaHref": { "type": "href", "binding": { "type": "static_value", "value": "mHjnXsgKbBlj_PZ-ctaHref" } },
"children": { "type": "slot", "slot": "7Opc3nDCi1wwpyy" }
},
"slots": {
"7Opc3nDCi1wwpyy": [
{
"uid": "Wo-7Ly0xHXWsOXQ",
"type": "doc-product-card",
"attrs": {},
"metadata": {},
"styles": { "default": { "classes": [""], "responsiveStyles": { "default": {} } } },
"props": {
"title": { "type": "string", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-title" } },
"image": { "type": "imageurl", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-image" } },
"price": { "type": "number", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-price" } },
"badge": { "type": "choice", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-badge" } },
"ctaHref": { "type": "href", "binding": { "type": "static_value", "value": "Wo-7Ly0xHXWsOXQ-ctaHref" } }
},
"slots": {}
}
]
}
}
]
}
}
Notes on the tree that aren’t in the recipe examples above:
- Every node carries attrs, metadata, styles, props, slots, even when empty ({}). Studio writes them. You should too.
- The root’s type is "page", the built-in Page system component. Freeform / Connected templates wrap their content in this root.
- props.children.type: "slot" + props.children.slot: "<slot-uid>": declares that this node’s children live in the matching key inside slots. Same pattern on nested slot-owning components.
- metadata.slotNames.<slot-uid>: "<author-visible name>": components with named slots (like doc-hero‘s "Actions" slot) register the label here so the canvas shows it.
- Each props.<name>.type ("string", "imageurl", "number", "href", "choice") is the widget kind. It must match the sub-bucket of entry.static_value the resolver looks in (see the two-level-lookup rule above).
The full CMA POST body
The ui value on the wire is the tree above, JSON-stringified, zlib.compressed, base64-encoded, and prefixed with zlib:. The rest of the entry:
{
"entry": {
"title": "Spring 2026 Landing",
"composable_uid": "spring_2026_landing",
"connected_content_type": "",
"url": "/campaigns/spring-2026",
"url_metadata": {
"url_source": "user_specified_pattern",
"url_queries": "{\"include\":[],\"only\":{},\"where\":{}}"
},
"place_composition_as": "page",
"schema_version": "1.0.0",
"data_sources": "[]",
"linked_schemas": [],
"linked_sections": [],
"static_value": {
"text": [
{ "key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio" },
{ "key": "mHjnXsgKbBlj_PZ-ctaLabel", "value": "" },
{ "key": "Wo-7Ly0xHXWsOXQ-title", "value": "Studio Pro" }
],
"textarea": [
{ "key": "mHjnXsgKbBlj_PZ-subhead", "value": "Visual composition for content teams — using the React components your engineering already built." }
],
"imageurl": [
{ "key": "mHjnXsgKbBlj_PZ-cover", "value": "" },
{ "key": "Wo-7Ly0xHXWsOXQ-image", "value": "" }
],
"href": [
{ "key": "mHjnXsgKbBlj_PZ-ctaHref", "value": "" },
{ "key": "Wo-7Ly0xHXWsOXQ-ctaHref", "value": "https://example.com" }
],
"number": [
{ "key": "Wo-7Ly0xHXWsOXQ-price", "value": 49 }
],
"choice": [
{ "key": "Wo-7Ly0xHXWsOXQ-badge", "value": [] }
],
"array": [],
"boolean": [],
"any": [],
"datestring":[],
"json_rte": [],
"html_rte": [],
"object": [],
"ui": "zlib:eNqtlFtvgjAUgP9L…"
}
}
}
Studio-generated _metadata.uid on each static_value.* entry is added by the CMA on write. Do not populate it yourself.
Python builder: produces the exact body above
Paste into a .py file, fill the two <…> placeholders, run against a fresh entry uid:
import base64, json, urllib.parse, zlib
UI_TREE = { ...the tree from § "The ui tree" above... }
COMPOSITION_ENTRY_UID = "<15-char CMA entry uid you'll POST to>" # optional if using composable_uid slug
raw = json.dumps(UI_TREE, separators=(",", ":"))
assert raw.count('"children"') >= 2, "children key corrupted before compression"
compressed = "zlib:" + base64.b64encode(zlib.compress(raw.encode())).decode()
body = {
"entry": {
"title": "Spring 2026 Landing",
"composable_uid": "spring_2026_landing",
"connected_content_type": "",
"url": "/campaigns/spring-2026",
"url_metadata": {
# Freeform + hand-chosen path → user_specified_pattern.
# Connected template → "content_type_url_pattern". NEVER omit this block.
"url_source": "user_specified_pattern",
"url_queries": json.dumps({"include": [], "only": {}, "where": {}}), # JSON STRING on the wire
},
"place_composition_as": "page",
"schema_version": "1.0.0",
"data_sources": "[]",
"linked_schemas": [],
"linked_sections": [],
"static_value": {
"text": [{"key": "mHjnXsgKbBlj_PZ-headline", "value": "Welcome to Studio"}, ...],
"textarea": [{"key": "mHjnXsgKbBlj_PZ-subhead", "value": "…"}],
"imageurl": [{"key": "mHjnXsgKbBlj_PZ-cover", "value": ""}, ...],
"href": [{"key": "mHjnXsgKbBlj_PZ-ctaHref", "value": ""}, ...],
"number": [{"key": "Wo-7Ly0xHXWsOXQ-price", "value": 49}],
"choice": [{"key": "Wo-7Ly0xHXWsOXQ-badge", "value": []}],
"array": [], "boolean": [], "any": [], "datestring": [],
"json_rte": [], "html_rte": [], "object": [],
},
"ui": compressed,
}
}
open("/tmp/entry.json", "w").write(json.dumps(body))
Then POST with curl -d @file (never -d "...", see § Never pass the compressed ui inline):
curl -X POST "https://<cma-host>/v3/content_types/documentation_compositions/entries" \ -H "api_key: <stack-api-key>" -H "$CS_AUTH" \ -H "Content-Type: application/json" \ -d @/tmp/entry.json
Publish the entry after create. See § Sanity-check the output below.