Strapi & 11ty as a headless CMS for Shopware 6

The challenge

One of the Shopware 6 online stores that we develop and maintain——was using two external, legacy CMS platforms (one of them was added later for more complex pages) for their so-called “SEO pages”. These were e.g. tutorials, FAQs, and wiki-like pages—in short, pages related to the store offer, serving as a knowledge base for customers and people interested in the technology and products.

Both CMSs were severely outdated and lacked some basic functionalities for content editors, and maintaining two separate systems was getting more and more troublesome. team wanted to decouple “SEO pages” editing from Shopware content editing, to make it more flexible and performant for the users, with the additional benefit of being independent from Shopware upgrades.

Therefore, the decision was made to migrate the pages to a new CMS platform.

At this point, we were considering a couple of possibilities

  • Shopware built-in CMS
  • Headless CMS backend and Shopware as the CMS frontend
  • Headless CMS backend and separate CMS frontend, use Shopware as a proxy to render the CMS pages embedded into the base page template
  • Headless CMS backend and separate CMS frontend, use Shopware API for fetching the necessary base page elements like header & footer

All of these solutions have their pros and cons, but we won’t go into them in this blog post. After a series of discussions we decided that in this particular case, the last option is the most suitable. The fact that in this scenario the CMS part remains mostly independent from Shopware 6 certainly played a large role in making this decision.

For us at NFC21, SEO is about creating rich content, to explain, demonstrate and encourage using our NFC products. Delivering a rich user experience with informational texts, tutorials, device databases and more is our mission. Thus, we needed more than an SEO-blog!

We came to the conclusion that we need a system that facilitates relations between content whilst delivering the best performance and UX possible.

Together with Kiwee, we came up with an approach that integrates seamlessly in our E-Commerce systems with a rock-solid foundation for future extensions to come.

Right from the beginning we knew, that a simple blog-like approach is not enough.

― Werner Gaulke-Sedlak ― CEO / Management at NFC21

Technology selection

There are many headless CMSs on the market. We assessed some of the popular solutions and decided to go with Strapi. The key factors were:

  • Good support for complex relational data modeling.
  • Extensive component system.
  • It is open-source!
  • Large community support.

Since the content of CMS pages was static and universal for all users, we decided to take a Jamstack-like approach and pre-render the pages using a static site generator. Our choice was Eleventy due to its simplicity, flexibility (both in terms of project structure and the use of templating engines), and no obligatory Javascript on the client side.

CMS Architecture

The architecture of the new CMS that we came up with consists of a few services.

Strapi Headless CMS that exposes an API to fetch content. Any changes made in the content should trigger the static pages generation via webhook;

Shopware page elements API Custom endpoints for Shopware that return parts of common between the SEO pages and the shop itself;

Eleventy (11ty) Static Site Generator that fetches the content from Strapi and the mentioned custom API and builds static pages. It processes the data and then renders HTML templates with it.

Headless CMS Architecture
Headless CMS Architecture

Headless CMS Implementation

The implementation process can be divided into three main stages.

(Re)creating content types in Strapi

The first step was to define content types. In Strapi, content types are collections of specific content. As a starting point, we created a UML diagram, including all entities from the old CMS. That was a good opportunity to review and improve the type definitions. From there we moved on to creating the actual content types and components in Strapi including all relations between them.


Keep in mind that a CMS page might not need a Strapi content type, and a content type doesn’t have to generate pages. For example, a content type named Author can contain entities for every content creator on your website. This data can be attached to the Article content type (through a relation), but you might choose to not have a page for every individual author.

Migrating the data from old CMS into Strapi

Once we had the content types defined, we proceeded with preparing the data migration scripts. We used database dumps from the old CMS, transformed the data to be compatible with Strapi content types, and created the entities with Strapi API.

CMS data migration flow
CMS data migration flow

Since every data migration is different and highly depends on the source and target systems and project-specific details, we will not discuss it in detail here. I want to mention a few aspects that gave us a bit of a headache and are easy to miss when migrating CMS content.

Shortcodes Many CMSs use shortcodes that represent repeatable blocks of HTML markup. When migrating to another platform, these shortcodes will no longer work. Depending on how extensively they were used in the previous implementation, you might want to either collect a list of usages for manual replacement, or come up with an automated script for replacing shortcodes with corresponding components in the new CMS.

Images Make sure to copy all the images used in the previous system. If the media path is going to be different after the migration, remember to update all images URLs in the content.

Custom Shopware API for providing crucial page elements

In order to maintain the same look and feel as in the Shopware store, it was crucial that the new CMS pages are embedded in the same header and footer, and have the same styling as the shop pages.

We’ve implemented a couple of custom API endpoints in Shopware that were responsible for:

  • Returning the header and footer templates. The endpoints returned HTML templates customized for the CMS pages.
  • Common meta tags to be included in the <head> of CMS pages.
  • CSS and JS bundles used by Shopware to ensure the same styling and interactivity in the header and footer.

Implementing page templates in 11ty

The last step was to re-implement page templates from the old CMS platforms in 11ty.

We decided that we would use the migration as an opportunity to refactor and improve them, instead of just re-implementing 1:1.

This involved optimizing the CSS, maximizing the utilization of standard Bootstrap classes and components, enhancing markup by introducing new elements and improving existing ones, and replacing HTML elements with the ones that are more semantically correct in a given context.

I want to share some of the most interesting aspects of using 11ty as a static site generator for Strapi CMS.

Understanding 11ty data cascade

Coming from frameworks such as Vue.js or React, which are more opinionated (at least in terms of data handling), 11ty requires a shift of thinking.

As with other aspects, 11ty is also very flexible when it comes to data handling. It allows you to add data at 7 different levels of the data cascade e.g. front matter in the templates, computed data, or global data to name a few.

With great flexibility comes greater complexity, and at the beginning, it was hard to decide where and how a given chunk of data should be stored. Should I store the collection of pages fetched from Strapi closely scoped, in template data files? But what if I need to render part of the same collection in another template, do I need to fetch it there again? So maybe it would be better to store it in the global data object?

In the end, it’s important to understand that in 11ty there are many ways of resolving the same issue, there is no single “good” way. We decided to store most of the collections in global data because we had quite a lot of “cross usage” of different collections between pages and it seemed the most convenient. Would it work if we went for the more limited, template-scoped data handling approach? Sure, it’s just another way of handling that.

Pagination: Useful not only for pagination

11ty offers a feature called Pagination. The name of this feature is somewhat misleading and hides its true potential. As per the documentation: “Pagination allows you to iterate over a data set and create multiple files from a single template”. This proved to be crucial not only for rendering collections of pages but also for single pages! How is that? All content is localized, so even a single page is technically a collection, having German and English translations.

Permalinks: How do I link to other pages?

It took us some time to figure out a reliable way of managing permalinks and cross-links between pages in 11ty.

Let’s say we are working on blog post pages. Each blog post has a slug field in Strapi, that serves as the base for permalink. In 11ty the slug was localized by adding language prefix (e.g. /en), content type prefix (e.g. /blog), and potentially category hierarchy. The final permalink can potentially look like this: /en/blog/tutorials/how-to-manage-permalinks-in-11ty. All of this “processing” is done in the blog post page template.

Now, assume that we have an Author page, and we want to link all blog posts of that author. But how do we get the permalink of the blog post? In the API response from Strapi, we should have the slugs of the blog posts written by a given Author. As you saw above, however, the final form of a permalink can be very different, so the slug itself is useless. Having the Blog Post permalink generation logic duplicated in the Author page template doesn’t sound like a good idea, so how should I do that?

After some going back and forth, and trying different approaches, we realized that 11ty Collections are what we were looking for. The collections object is available in all templates and allows you to access the attributes of other pages (including permalink).

Using a combination of tagging page types with unique tags, and implementing a custom permalink() filter in Nunjucks, we can easily link between different page templates.

Front matter data of the Blog Post template.
tags: posts

Link from Author page template to a Blog Post, using combination of 11ty collections and custom getPermalink() filter.
<a href="{{ collections.posts | getPermalink( }}">{{ post.title }}</a>

Simplified permalink filter implementation.
function getPermalink(collection, id) {
    return collection.find(
        (page) => === id

Rendering Dynamic Zones from Strapi

Dynamic Zones are a way of composing content out of Strapi components in a flexible way.

Example of a dynamic zone component int Strapi
Example of a Dynamic Zone. Content editors can choose from the available components: Rich text, Product, and Media slider

There are many ways to implement Strapi components rendering in 11ty. You could use Nunjucks macros, embed Vue or React and create components using them, or use 11ty’s tool — WebC.

In the beginning, we were using Nunjucks macros, however, there is one serious issue with this approach – they don’t support asynchronous code. We will see why this is a deal-breaker for some components in a minute. Because of that, and other advantages over macros such as automatic assets bundling, we switched to WebC.

The idea is to render all the components from a dynamic zone in a loop, passing the parameters to a middleware <component>.

index.njk Rendering the dynamic zone (pageData.content) components inside of a Nunjucks template
{% for it in pageData.content %}
   {% renderTemplate "webc", { it: it } %}
     <component :params="it"></component>
   {% endrenderTemplate %}
{% endfor %}

component.webc Implementation of the middleware <component>
<script webc:type="js">
 const componentName = params.__component.split(".")[1];

 switch (componentName) {
   case 'product':
     `<product :productId="params.productId"></products>`
   case 'rich-text':
     `<rich-text :content="params.content"></rich-text>`
   case 'media-slider':
     `<media-slider :media=""></media-slider>`

The power of WebC

Let’s take a closer look at WebC capabilities, using the <product> component as an example.

<script webc:type="render" webc:is="template">
 module.exports = async function () {
   // Get the product box template from custom Shopware API endpoint
   const productBox = await this.fetchShopwarePageElements(product-box/${this.productId});

   // Render the product box HTML
   return `
   <div class="product-wrapper">

 .product-wrapper {
   background-color: lightgray;
   padding: 1rem;

The most interesting part is how WebC allows us to dynamically fetch the content required to render a given component. This is the place where Nunjucks macros fall short as they don’t allow for asynchronous calls.

This component is also adding some custom CSS in the <style> tag. Thanks to WebC bundler mode, this CSS will be only loaded when you visit a page with this specific component. You just need to add

<style @raw="getBundle('css')" webc:keep></style>

to the <head> and voila! We have component-driven CSS for free.

Lessons learned

Working on this project was really interesting and we learned a lot about the challenges of Headless CMS in general, as well as Strapi and 11ty specifically. Let’s take a look at some of the takeaways.

11ty build times

When working with 11ty it’s easy to get in the trap of not caring about the build performance that much. The code is executed only during the build, so it doesn’t affect the end user. Besides, it’s just building some static pages, so it should be quick anyway, right?

Turns out, it’s not so obvious when you are working with relatively large collections of pages. In our case, it generates a total of ~2500 with each build, and the time it takes is much longer than we would have expected at the beginning. As a consequence, editors had to wait quite a long time to see the changes made on the live instance, and the risk of blocking the build pipeline with too many parallel builds increased.

After some optimizations, we managed to achieve reasonable build times, but it’s still not ideal. We plan to implement more extensive caching, including both API responses and the data transformed in 11ty, to improve that.

Strapi is not as mature as we thought

We selected Strapi as the headless CMS platform of choice after some research. In comparison with other platforms, it seemed the most mature, with the biggest community behind it, and overall the safest option for our first headless CMS project.

I don’t think we would choose another platform if we had to make that decision again, knowing what we know now. Nonetheless, there are some missing pieces that, having worked with WordPress or Shopware built-in page builder, we expected Strapi to have.

Layout management limitations

A pretty common scenario in the CMS world: you have some components, and you want to place them in a grid, e.g. add the image component as the first column, and the text component as the second column next to it.


Text paragraph

Turns out, there is no way to achieve this in Strapi (as of today, v4.19).

Our first thought was, “Let’s just implement it ourselves by creating Strapi components for rows and columns, right?”

Well, not really. It’s not possible to add Dynamic Zones to components, meaning that you would need to add a separate field for each component that can be added to that column. This is not what we want, and it wouldn’t scale at all, as the number of components grows.

Right now, this is one of the most requested features by the Strapi community, and judging from this thread in the Strapi feedback platform, we weren’t the only ones who assumed that this feature would be available in such a mature CMS. Even though this was first requested back in 2020 and there is high demand for it, it’s still not even included in the Strapi roadmap.

All media files are stored in a single flat directory

In most of the popular frameworks media files are organized into directories in some way or another. For example, WordPress will split the media by year and month, uploading the file to /wp-content/uploads/2024/01/. Strapi, on the other hand, uploads everything to the /uploads folder, in a flat structure. Even if you use the “Folders” functionality in the Strapi Dashboard, the file still lands in the same /uploads directory on the filesystem level.

This may cause directory scanning performance issues as the number of media files in that single folder grows. Especially if you have the “Responsive friendly upload” feature turned on, one upload will result in 5 different files.

Related entity assignments are not localized

Let’s say we have two content types in Strapi, Smartphone and Vendor, and both types can be translated into two languages: German and English. The pages representing Smartphone: Iphone 14 should have the same Vendor: Apple, regardless of the language. In Strapi, it is possible to set a field or relation as “common for all locales”. It works as follows: if you assign Apple as the vendor of the German translation of Iphone 14, the English version will automatically have the same vendor assigned, so content editors don’t need to repeat it for every language.

The problem that we didn’t anticipate at the beginning, is that the relation assignment is not localized automatically. It’s not a Strapi bug though, because the option literally says “common for all locales”.

Example of a dynamic zone component in Strapi

As a result, we couldn’t rely on the related entity ID returned from Strapi API. It could be both English and German, depending on what translation it was set first.

We just incorrectly assumed that it works this way. It would be a nice option to have, even though it doesn’t make any sense for fields other than entity relations. Another possibility would be to override the controller of Smartphone content type to make sure the related Vendor is always localized in the API response.

Field labels and descriptions are not part of the model

It’s often useful to add a meaningful label and description to a field. Take for example the productId field that we have in one of the components. Should it be the product UUID from Shopware? Or maybe the product SKU? To make life easier for content editors, we use labels and descriptions to provide context for such fields.

When you create a content type of Component in Strapi, a corresponding JSON file is created, containing a list of all the fields, their type, if they are required, etc. In Strapi, it’s called the schema file.

Strapi decided not to store the field label and description (and some other properties) in the model, but rather in the database. It means that transferring it between instances requires using config:dump and config:restore commands. We think that such a trivial feature should not require as much effort, but at least there is a way to do that.

They listed the reasons why they decided to take this approach in their FAQ section. It’s not very convincing, since other CMS platforms that we use don’t have this problem.

Final words

This article should give you an overview of what a migration to a headless CMS can look like. We also delved into specific implementation issues that we ran into while working with Strapi and 11ty. Hopefully, it adds something to your headless CMS toolbox and you will be able to apply some of these concepts in your project.

This project has been very interesting to work on, and despite many challenges along the way, we are happy with the outcome: A “knowledge base” with fast, static pages and CMS that is independent of Shopware 6. Check out the result on!