Rendering CMS Pages
Goal
Render a Shopping Experiences page: walk its sections, blocks and slots and map each one to a component. The important part is that the whole tree arrives in the request that fetched the category or landing page, so rendering is a resolution problem rather than a data-fetching one.
Shopware Flow
readCms post /cms/{id} exists and nothing in Shopware Frontends calls it. The CMS page comes back nested inside the entity instead: POST /category/{navigationId} and POST /landing-page/{landingPageId} both resolve the assigned layout server-side and return it as cmsPage, whether or not the criteria asked for it. useLandingSearch().search(id, { withCmsAssociations: true }) widens the criteria around that with the media association and a deeper cmsPage association tree. useCategorySearch().search() takes the same flag but nests the association object one level too deep, so it requests neither — and the category layout still arrives. The flag is not what produces the tree, and a page that renders without it is no evidence that it was applied.
What comes back is CmsPage → sections → blocks → slots. Every level carries a type, and that type is the component name. There is no registry to consult — the renderer pascal-cases the type into CmsSection<Type>, CmsBlock<Type> or CmsElement<Type> and asks Vue to resolve it.
Step 1
Composable: Fetch the entity
The CMS page is not fetched on its own. The category and landing page routes resolve the layout server-side and return it as the entity's cmsPage property, so withCmsAssociations is not what produces it.
- Code
search(navigationId)- State
- category or landing page
- Types
- readCategory body
Read the diagram from left to right:
- The page fetches its entity; the route returns the resolved
cmsPagealong with it. - The response carries the full
cmsPagetree — sections, blocks and slots. - Each section's
typeis resolved to aCmsSection<Type>component. useCmsSection(section).getPositionContent(position)groups the blocks by section position.useCmsBlock(block).getSlotContent(name)picks one slot for an element component.- On a category page the embedded product listing seeds the shared listing context.
useCmsMeta(entity)supplies the title and meta tags for the document head.
You do not need a request per section, block or element. The only later requests are the ones an element makes for itself — a cross-selling slider, a product listing page change.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Fetch a category page | useCategorySearch().search(id) | POST /category/{navigationId} | readCategory body |
| Fetch a landing page | useLandingSearch().search(id, { withCmsAssociations: true }) | POST /landing-page/{landingPageId} | readLandingPage body |
| Read the CMS page | entity.cmsPage | either | CmsPage |
| Read one section | useCmsSection(section).section | none | CmsSection |
| Read blocks by position | getPositionContent("main") | none | CmsBlock |
| Read one slot | getSlotContent("left") | none | CmsSlot |
| Fetch a page by id directly | invoke("readCms post /cms/{id}", { pathParams: { id }, body: { slots } }) | POST /cms/{id} | readCms body |
POST is the request-layer default, but not what the supported template does: vue-starter-template sets cacheableReads: true under runtimeConfig.public.shopware, and with that flag useCategorySearch calls readCategoryGet get /category/{navigationId} instead, compressing the same criteria into a _criteria query param so the read is HTTP-cacheable. useLandingSearch has not been moved to the cacheable variant and always posts. Either way the same cmsPage tree comes back.
The last row is the operation nothing uses. It takes a slots string of |-separated identifiers to resolve only some slots, and a ProductListingCriteria for the listing an element on the page may hold.
Composables
Pick by scope — how much of the CMS page the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useCategorySearch | a category page | fetching the entity whose cmsPage you are about to render |
useLandingSearch | a landing page | the same, for a landing page |
useCategory | the current category | reading the category a page already fetched, as a ComputedRef |
useNavigationContext | the current route | deciding whether the page behaves as a category page |
useCmsMeta | the entity | filling the document title and meta tags |
useCmsTranslations | every CMS component | overriding a component's fallback strings per locale |
useCmsSection | one section | splitting a section's blocks by position |
useCmsBlock | one block | picking the slot an element component renders from |
useCmsElementConfig | one element | reading what the admin configured on that element |
useCmsElementImage | one media element | rendering an image without re-deriving its attributes |
The tree walk is what you use on every page:
- Sections —
useCmsSection(section)returnssectionandgetPositionContent(position), which filters the section's blocks bysectionPosition(main,sidebar). - Blocks —
useCmsBlock(block)returnsblockandgetSlotContent(slotName), which matches a slot on its ownslotname. That name is whatever the block declares;left,right,contentandcenterare the common ones. - Both take the section or block itself, a
refto it or a getter, and read whichever you passed on every lookup — souseCmsBlock(() => props.content)with the lookups wrapped in acomputedis what follows a replaced prop. That is what every block component in the CMS base layer does. - Elements —
useCmsElementConfig(element)returnsgetConfigValue(key)for the admin configuration, anduseCmsElementImage(element)derivesimageAttrs,anchorAttrs,imageContainerAttrs,imageLink,containerStyle,displayMode,ariaLabel,isDecorative,isVideoElementandmimeTypefor an image or manufacturer-logo element.
Five things the generated reference will not tell you:
- Only the lookups follow a replacement, and only if you hand them something to follow.
getPositionContentandgetSlotContentread the current value on every call, but thesectionandblockthe composable returns are the value read when it was called.useCmsElementConfiganduseCmsMetafollow nothing at all and take the object itself: arefmakes the first returnundefinedand the second produce empty strings, in both cases without an error. getSlotContent(name)isArray.findwith a cast. A slot the block does not have isundefinedat runtime while the type promises a value.getConfigValue(key)returnsfalse, not the value, when the entry'ssourceis"mapped"— the case where the value comes from the surrounding entity rather than from the layout.useCmsMeta(entity)returns atitleand ametacomputed, and it takes the entity, not a ref to it. Neither template calls it directly; both wrap it in their ownuseCmsHead(entity), which unwraps the ref, passes the title, description and Open Graph tags touseSeoMeta, and the remaining meta entries and the canonical link touseHead.useCmsElementConfiganduseCmsElementImagelive inpackages/composables/src/cms/rather than in ause*directory of their own, so the generated reference — built one page peruse*directory — has no page for them.
resolveCmsComponent(content) is exported from @shopware/composables alongside them. It derives the component name from content.type and content.apiAlias and attempts the resolution for you.
Two names in this flow come from outside the CMS set. useNavigationContext().routeName is what the base layer's CmsPage component checks before it lifts the embedded listing. createCategoryListingContext(initialListing) and useCategoryListing() both come from useListing: CmsPage creates that context — and only when getProductListingFromCmsPage actually finds a listing — the product listing element consumes it, and the element throws when nothing created it. Neither template holds these calls; they live in packages/cms-base-layer.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type the CMS tree, the criteria, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type ReadCmsBody = operations["readCms post /cms/{id}"]["body"];
type CmsPage = Schemas["CmsPage"];
type CmsSection = Schemas["CmsSection"];
type CmsBlock = Schemas["CmsBlock"];
type CmsSlot = Schemas["CmsSlot"];Each of the four carries a type and an apiAlias. The first is what the component name is built from; the second picks the prefix below the page level: resolveCmsComponent maps cms_section to CmsSection, cms_block to CmsBlock, and treats everything else — cms_slot included, and any alias it does not know — as CmsElement. The CmsPage itself is never resolved to a component; the renderer walks straight into its sections.
Minimal Vue Example
<script setup lang="ts">
import { getCmsLayoutConfiguration } from "@shopware/helpers";
import { pascalCase } from "scule";
import { computed, resolveComponent } from "vue";
import type { Schemas } from "#shopware";
const { content } = defineProps<{ content: Schemas["CmsPage"] }>();
const resolveSection = (section: Schemas["CmsSection"]) => {
const component = resolveComponent(`CmsSection${pascalCase(section.type)}`);
return typeof component === "string" ? null : component;
};
const sections = computed(() =>
(content.sections ?? []).map((section) => ({
section,
resolved: resolveSection(section),
layout: getCmsLayoutConfiguration(section),
})),
);
</script>
<template>
<template v-for="{ section, resolved, layout } in sections" :key="section.id">
<component
:is="resolved"
v-if="resolved"
:content="section"
:class="layout.cssClasses"
:style="{ backgroundColor: layout.layoutStyles?.backgroundColor }"
/>
<p v-else>No component for section type {{ section.type }}.</p>
</template>
</template>The component name is built at runtime, so Nuxt cannot rewrite resolveComponent into a static import and the lookup falls back to the globally registered components. The section components therefore have to sit under a path registered global: true — app/components/cms/ in vue-starter-template — or every section renders the fallback.
A section component then does the same one level down. useCmsSection(() => content) gives it getPositionContent(position) for its blocks, and each block component uses useCmsBlock(() => content) and getSlotContent(name) to reach its elements. The element at the end of that walk is where rendering stops being generic: it reads what the admin configured with const { getConfigValue } = useCmsElementConfig(content) and renders its own markup from those values.
State And Session
Almost nothing here is state. useCmsSection and useCmsBlock resolve whatever they were handed on each lookup, and useCmsMeta wraps its output in computeds, but nothing is stored, no context is provided and no request is made. They are helpers with a composable's naming. useCmsMeta is the one that still insists on the entity rather than a ref to it, and says nothing when it gets one.
The two places state does appear are worth knowing. useCmsTranslations() injects whatever the application provided under cmsTranslations, so a CMS component's fallback strings can be overridden per locale without prop drilling. And on a category page the base layer's CmsPage lifts the product listing out of the CMS payload with getProductListingFromCmsPage and seeds the shared listing context with createCategoryListingContext(initialListing) — which is why a category listing renders products before any listing request is made. What the listing composable then does with that seed — the initial listing, the applied one that shadows it, and the filters on top — is the Product Listing and Filters recipe.
The CMS payload is context-dependent like everything else. Prices inside a product element are calculated for the current currency and tax state, and visibility on a section or block can hide it for a given device. A currency or language switch invalidates the whole rendered page.
That payload is also the one a shared cache may store. With the starter's cacheableReads: true the category read comes back public, s-maxage=1800, where the POST variant is private, no-cache — so the CMS tree and the calculated prices of the embedded listing sit in the same cacheable response. Keeping one customer's prices out of another's page is the backend's contract rather than the renderer's: those entries are varied on language, currency and login state, which Backend HTTP cache and reverse proxy explains. The frontend guards one thing itself — @shopware/api-client ignores sw-context-token on a publicly cacheable response, so a replayed guest token from a cache hit cannot overwrite a logged-in session.
Edge Cases
readCms post /cms/{id}is never called by Shopware Frontends. Debugging a missing block means looking at thecmsPagethe category or landing page response already carried, not at that operation.withCmsAssociationsis not what makescmsPageappear. Both routes resolve the layout on their own, so removing the flag does not reproduce a missing-layout bug, and adding it does not fix one.useCmsSectionanduseCmsBlockaccept the object, arefor a getter, but a plain object is still a snapshot: it is the value you read at setup, not a live one. Hand them() => props.contentwhen the prop can be replaced.- Calling a lookup once is not enough either.
getPositionContentandgetSlotContentre-read on every call, so the value has to be read again to change — wrap each one in acomputed. sectionandblockdo not follow a replacement at all; they are the value read when the composable was called. Read the prop directly when you need the current one.getSlotContent(name)returns the result ofArray.findcast to a slot. A missing slot isundefinedat runtime while the type claims a value, so guard on it.getPositionContent(position)returns an empty array for a position no block uses. That is the normal way a section with an unused side column behaves.resolveCmsComponent()reportsisResolvedfrom whether a component came back, so it isfalsefor a type nothing is registered under. It also returns aresolvedfield when the resolution throws; that one is deprecated and always equalsisResolved.resolveComponentmust be called during render or setup. Calling it in a plain module function outside a component context logs a Vue warning and resolves nothing.- A component whose name is built at runtime only resolves if it is registered
global: true. Dropping aCmsSection*orCmsElement*override into plainapp/components/leaves it out ofresolveComponent's reach, and the fallback renders with no error — in the starter the registered path isapp/components/cms/. useCmsMeta(entity)closes over the entity it was given. It reads the entity's meta fields, not the CMS page's, and does not follow a replacement.useCmsMetatakes the entity, not a ref.useCategory()anduseAsyncDataboth hand you aRef, andgetTranslatedPropertyfalls back to""for anything it cannot read — so a ref produces an empty title and no meta entries, without an error.getConfigValue(key)returnsfalsefor a config entry whosesourceis"mapped". Mapped values come from the surrounding entity (a product page element bound to the product), so an element that only readsgetConfigValuerenders nothing there.useCmsTranslations()returns{}when nothing was provided. A component relying on it has to keep its own defaults, which is what the CMS base layer does withdefu.- The embedded listing is lifted only when
routeNameisfrontend.navigation.page. A product listing element on a landing page therefore finds no context anduseCategoryListing()throws instead of rendering empty. The admin only offers the listing block on a listing layout, so this surfaces with hand-built layouts rather than in normal editing. visibilityon sections and blocks is per breakpoint. Server-rendering everything and hiding with CSS is the intended behaviour, not a bug.
Common Mistakes
- Do not fetch the CMS page separately. The route already returned it on the entity.
- Do not hand
useCmsSectionoruseCmsBlocka plain prop when it can be replaced. Pass a getter. - Do not read a lookup once at setup. Wrap
getPositionContentandgetSlotContentin acomputed. - Do not read
sectionorblockexpecting the current content. They are a snapshot. - Do not use
getSlotContentwithout a guard. The cast hides a possibleundefined. - Do not call
resolveComponentoutside a component's setup or render. - Do not put a CMS component override outside a path registered
global: true. - Do not read meta tags off the CMS page.
useCmsMetatakes the entity, unwrapped. - Do not issue a listing request on a category page before checking whether the CMS payload already carried one.
- Do not assume a rendered CMS page survives a currency or language switch.
Testing Checklist
- A category page gets its layout from the category request itself — a
GET /category/{navigationId}?_criteria=…with the starter template'scacheableReads: true, aPOSTwithout it — and never a separatereadCms post /cms/{id}request. - Every section in the payload resolves to a component, and an unknown type renders the fallback rather than nothing.
getPositionContentreturns only the blocks whosesectionPositionmatches.getSlotContentreturnsundefinedfor a slot the block does not have, without throwing, and the element renders nothing rather than an empty wrapper.- Replacing the
contentof a mounted block changes what its slots render, without a remount. - A category page renders its first page of products before any listing request is sent.
- A product listing element on a landing page fails loudly through
useCategoryListinginstead of rendering an empty listing. useCmsMetaproduces a title from the entity's translated name and meta entries only for the fields that are set.- A component reading
useCmsTranslations()renders its own defaults when nothing was provided. - An element reads its admin configuration through
getConfigValue, and a key the layout does not set comes backundefined. - Switching the language re-renders the page with translated CMS content.