CMS Integrations
Composable Frontends does not force one content source. Shopware can render its own Shopping Experiences, and your storefront can also read from a headless CMS such as Storyblok, Strapi, Sanity, Contentful, Hygraph, Builder.io, or a custom editorial API.
The important architectural rule is simple:
let the CMS own editorial content, and let Shopware own live commerce data.
That means the CMS can decide what appears on a page, but product names, prices, availability, variants, cart, checkout, customer data, and promotions should still come from the Shopware Store API at request time.
Existing examples
Choose an integration model
Most integrations fit into one of these patterns:
| Pattern | Good for | How it works |
|---|---|---|
| CMS as page source | Landing pages, editorial homepages, campaign pages | The external CMS resolves the route and returns a page builder payload. Vue components render each CMS block. |
| CMS as section source | Global banners, teasers, content slots in product/category pages | Shopware still resolves the main page. The external CMS fills specific areas. |
| Shopware CMS fallback | Mixed migrations, gradual adoption, legacy Shopware Shopping Experiences | Try the external CMS first or only when Shopware does not resolve a route. |
| Product references in CMS | Merchandising sections, product carousels, buying guides | The CMS stores Shopware product IDs. The frontend resolves live product data with Shopware composables. |
Use the smallest pattern that solves the project. A global campaign banner should not need a full route resolver. A full editorial site with nested pages usually should.
Recommended architecture
Keep the integration as a thin adapter between three layers:
- CMS client: fetches typed content from the external CMS.
- CMS resolver: maps a route, locale, and request context to a normalized page.
- Vue renderer: maps CMS block types to Vue components.
This keeps vendor SDKs out of your components and makes it easier to replace a CMS later.
type CmsRouteContext = {
path: string;
locale: string;
salesChannelId?: string;
};
type CmsBlock = {
id: string;
type: string;
props: Record<string, unknown>;
};
type CmsPage = {
title?: string;
seo?: {
title?: string;
description?: string;
};
blocks: CmsBlock[];
};
type CmsAdapter = {
resolvePage(context: CmsRouteContext): Promise<CmsPage | null>;
};Then implement the adapter with the CMS tooling your project uses. The rest of the storefront should only depend on the normalized contract:
export function createCmsResolver(adapter: CmsAdapter) {
return async (context: CmsRouteContext) => {
const page = await adapter.resolvePage(context);
if (!page) return null;
return {
...page,
blocks: page.blocks.filter((block) => Boolean(block.type)),
};
};
}The adapter can call a REST API, GraphQL API, SDK, or internal service. Keep that vendor-specific code in one place:
const cmsAdapter: CmsAdapter = {
async resolvePage(context) {
const rawPage = await fetchFromYourCms(context);
if (!rawPage) return null;
return normalizeCmsPage(rawPage);
},
};Rendering CMS blocks
A page builder should be explicit. Map every external block type to one Vue component, and render nothing for unknown blocks in production. In development, show a small placeholder so the missing component is obvious.
<!-- app/components/ExternalCmsPage.vue -->
<script setup lang="ts">
import type { Component } from "vue";
import CmsHero from "./external-cms/CmsHero.vue";
import CmsRichText from "./external-cms/CmsRichText.vue";
import CmsFeaturedProducts from "./external-cms/CmsFeaturedProducts.vue";
defineProps<{
blocks: Array<{
id: string;
type: string;
props: Record<string, unknown>;
}>;
}>();
const components: Record<string, Component> = {
hero: CmsHero,
richText: CmsRichText,
featuredProducts: CmsFeaturedProducts,
};
</script>
<template>
<template v-for="block in blocks" :key="block.id">
<component
:is="components[block.type]"
v-if="components[block.type]"
v-bind="block.props"
/>
<div v-else-if="import.meta.dev" class="border border-dashed p-4 text-sm">
Missing external CMS component: {{ block.type }}
</div>
</template>
</template>This is the same idea used by the Sanity example: Sanity provides a pageBuilder array, while Vue maps each _type to a section component.
Resolving routes
If the external CMS owns complete pages, resolve it from the catch-all route. If Shopware should remain the primary router, use the Multiple CMS middleware pattern and only render the external CMS when Shopware does not return a route component.
<!-- app/pages/[...all].vue -->
<script setup lang="ts">
const route = useRoute();
const { locale } = useI18n();
const { resolvePage } = useExternalCms();
const path =
route.path.replace(`/${locale.value}`, "").replace(/^\//, "") || "home";
const { data: page } = await useAsyncData(
`external-cms-${locale.value}-${path}`,
() => resolvePage(path, locale.value),
);
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: "Page not found" });
}
useSeoMeta({
title: page.value.seo?.title ?? page.value.title,
description: page.value.seo?.description,
});
</script>
<template>
<ExternalCmsPage :blocks="page?.blocks ?? []" />
</template>Connecting content with commerce
Do not copy product data into the CMS. Store stable Shopware identifiers and resolve them during SSR with Shopware composables.
<!-- app/components/external-cms/CmsFeaturedProducts.vue -->
<script setup lang="ts">
import type { Schemas } from "#shopware";
const props = defineProps<{
heading?: string;
productIds: string[];
}>();
const { search } = useProductSearch();
const { data: products } = await useAsyncData(
`external-cms-products-${props.productIds.join("-")}`,
async () => {
const resolved = await Promise.all(
props.productIds.map((id) =>
search(id)
.then((response) => response.product)
.catch(() => null),
),
);
return resolved.filter(
(product): product is Schemas["Product"] => !!product,
);
},
);
</script>
<template>
<section>
<h2 v-if="heading">{{ heading }}</h2>
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
</section>
</template>This is the safest split for commerce projects:
- CMS stores product IDs, copy, layout, campaign images, and editorial order.
- Shopware resolves price, stock, translated product names, media, variants, and purchase actions.
- Cart, checkout, and customer account flows stay entirely in Shopware composables.
Match the sales channel
Product IDs must belong to the same Shopware sales channel as the access token used by the storefront. If editors paste IDs from another environment, the Store API can return 404 or products that are not visible in the current channel.
Caching strategy
Treat CMS content and commerce data differently.
| Data | Cache recommendation |
|---|---|
| Published CMS pages | Cache on the server or CDN, then revalidate on publish webhooks. |
| Draft CMS pages | Never share-cache. Use private, no-store. |
| Product cards resolved by ID | Render during SSR, but keep freshness aligned with Shopware cache rules. |
| Cart, customer, wishlist | Client/session data only. Do not include it in cacheable HTML. |
When using Nuxt routeRules, cache public editorial routes but keep personalized routes such as /checkout, /account/**, and cart flows out of ISR.
Localization
Pass the active storefront locale to the CMS resolver and keep a clear fallback policy:
const { locale } = useI18n();
const page = await resolvePage(path, locale.value);For multi-language storefronts, align:
- Nuxt route prefixes
- Shopware sales channel language
- CMS locale codes
- canonical URLs and
hreflang - translated product IDs or product references, if the CMS stores environment specific references
Content modeling checklist
Before implementing the integration, define these contracts with the content team:
- Which system owns each route?
- Which CMS blocks are allowed on product and category pages?
- What is the stable reference for Shopware products: product ID, product number, or a custom field?
- Should editors select products manually, through a filtered query, or through a curated list?
- How are draft, published, scheduled, and archived entries represented?
- Which fields are required for SEO, Open Graph, breadcrumbs, and structured data?
- How should the frontend behave when a CMS block references a missing product?
Security and reliability
External CMS data is still untrusted input. Render it carefully:
- sanitize rich text if the CMS returns HTML instead of structured rich text
- allowlist block types and component props
- keep private tokens in server runtime config
- validate webhook signatures before purging caches
- handle missing CMS entries with real
404responses - add observability around CMS fetch failures and slow responses
- keep checkout and account flows independent from CMS availability
Where to go next
- Sanity integration: page builder, live Shopware products, cart, and a runnable Nuxt example.
- Strapi integration: global banner and route fallback example.
- Storyblok integration: Nuxt module setup and visual editor oriented component rendering.
- Multiple CMS: render an external CMS as a fallback next to Shopware Shopping Experiences.
- Shopping Experiences: customize the Shopware CMS renderer and default CMS components.

