Integrating commercetools Frontend with a headless CMS

This document presents the key decisions and actions for integrating commercetools Frontend with a content management system (CMS), illustrated with a sample integration scenario.

The main aspects of the integration concern content management and rendering strategies, content modeling in the CMS, and mapping the content model to commercetools Frontend types.

Key decisions and actions

  1. Define if you want to manage content from the Studio or if you want to manage it completely from the CMS. You can consider using commercetools Frontend and a CMS, without using the Studio. However, using the Studio with a CMS lets you get the best of both tools.
  2. Define if you want to render your content dynamically or statically, or both ways.
  3. Define a content model on the CMS based on the elements that make up your pages and that you need to manage. For example, banners, hero sections, pages, stories, and product references.
  4. Define on the CMS the fields that compose the content elements. For example, a story might consist of title, subtitle, text, media, and slug. However, a page might be more complex and could consist of title, slug, and slots, where slots are containers of other elements, for example, a story or a hero.
  5. Map the content model from the CMS to the commercetools Frontend types to render it on the frontend. You must define what data you want to render from the CMS to your frontend.
  6. Map your frontend and your CMS architecture to the UI components available out-of-the-box with commercetools Frontend or create new ones based on your needs. For example, the out-of-the-box hero component can't contain products within it, but, if supported by your CMS, you can create a custom component that allows this instead.

After this, you can proceed with development and creation of data sources, Frontend components, static pages, and dynamic pages based on your decisions.

Example scenario

Let's see an example of integration with a CMS by looking at three sample pages. In such a scenario, business users create and manage the entire content from the CMS editor, where they can control all settings such as text formatting, embedded content, image layout, etc. Developers, on the other hand, work on the code and from the Studio to manage the page configuration.

This approach has the following advantages:

  • Business users, who typically work in a CMS, don't have to learn a new tool on top of the CMS.
  • The CMS provides business users with advanced content management and content reuse features.
  • The Studio provides developers with an intuitive drag and drop interface to build pages with components.

The example uses Contentful as the CMS but the principles apply to any CMS integration.

Static story page

In addition to header and footer, the story page consists of the following main items: the story content, a product slider, and the list of the latest stories.

The story content on the story static page

The product slider on the story static page

The latest stories on the story static page

The page is implemented as a static page in the Studio consisting of the following Frontend components:

The structure of the static story page in the Studio's page builder

In this case, the image and text of the story are created by business users from the CMS and displayed through the contentful Content Frontend component, which retrieves the story with a specific ID.
The products in the slider are retrieved by commercetools Frontend from commercetools Composable Commerce and displayed through the commercetools UI product slider Frontend component.
The list of the latest stories is retrieved from the CMS and displayed through the contentful Content List; this means that the list automatically updates and displays new stories created by business users on the CMS based on their creation date. By clicking on one of the stories, a dynamic story page opens.

Dynamic story page

In addition to header and footer, the story page consists of the following main items: the story content and the list of the latest stories.

The story content on the story dynamic page

The latest stories on the story dynamic page

The page is implemented as a dynamic page in the Studio consisting of the following Frontend components:

The structure of the dynamic story page in the Studio's page builder

In this case, all the content of the story is created by business users from the CMS and displayed through the contentful Content Frontend component, which retrieves the story based on the URL path. The product is also part of the story as an embedded element that business users add from the CMS editor. In this case, we leverage a CMS feature that lets you retrieve product data from a third party service like commercetools Composable Commerce.
Like on the static story page, the list of the latest stories is dynamically retrieved from the CMS and displayed through the contentful Content List Frontend component.

Dynamic home page

In addition to header and footer, the home page consists of the following main items: hero banner, category slider, category images for promotions, and new arrival product slider.

The hero banner and the category slider on the home dynamic page

The category promotion images and the new arrival slider on the home dynamic page

The page is implemented as a dynamic page in the Studio consisting of the following Frontend components:

The structure of the dynamic home page in the Studio's page builder

In this case, like for the dynamic story page, all content on the page is created by business users from the CMS and displayed through the contentful Content Frontend component, which retrieves the page content based on the URL path. The products are also part of the page content as an embedded element that business users add from the CMS editor.

From the code point of view

The following are excerpts of sample code files used in the preceding examples.

Mapper

The mapper file defines a ContentfulMapper class, which maps the CMS data types (like pages and stories) into types used by commercetools Frontend. For example, it determines that to load a story from the CMS and render it on the frontend, the story must have an ID and text, and can have media assets.

Mapper fileTypeScript
import { Asset as ContentfulAsset, Entry, EntryFields } from 'contentful';
import { BLOCKS } from '@contentful/rich-text-types';
import { Category } from '@Types/product';
import { Asset } from '@Types/content';
import {
// ... other code
Page,
Slot,
Story,
} from '@Types/content/FrontendTypes';
import {
// ... other code
TypePage,
TypeSlot,
TypeStory,
} from '../interfaces/ContentfulTypes';
// ... other code
export class ContentfulMapper {
static contentfulAssetToAsset(contentfulAsset: ContentfulAsset): Asset {
return {
url: contentfulAsset.fields.file.url,
title: contentfulAsset.fields.title,
description: contentfulAsset.fields.description,
contentType: contentfulAsset.fields.file.contentType,
};
}
// ... other code
static async contentfulStoryToStory(
story: TypeStory,
productApi: ProductApi
): Promise<Story> {
return {
id: story.sys.id,
...story.fields,
text: await this.contentfulRichTextToRichtext(
story.fields.text,
productApi
),
media: story.fields.media?.map(this.contentfulAssetToAsset),
dynamicPageType: 'contentful/story',
};
}
static async contentfulSlotToSlot(
slot: TypeSlot,
productApi: ProductApi
): Promise<Slot> {
return {
...slot.fields,
id: slot.sys.id,
items: await Promise.all(
slot.fields.items.map(async (value) => {
return await this.resolveByType(value, productApi);
})
),
};
}
static async contentfulPageToPage(
page: TypePage,
productApi: ProductApi
): Promise<Page> {
return {
...page.fields,
id: page.sys.id,
dynamicPageType: 'content/page',
slots: await Promise.all(
page.fields.slots.map(
async (slot) => await this.contentfulSlotToSlot(slot, productApi)
)
),
};
}
static resolveByType = async (value: Entry<any>, productApi: ProductApi) => {
switch (value.sys.contentType.sys.id) {
case 'story': {
return await this.contentfulStoryToStory(
value as TypeStory,
productApi
);
}
case 'slot': {
return await this.contentfulSlotToSlot(value as TypeSlot, productApi);
}
case 'page': {
return await this.contentfulPageToPage(value as TypePage, productApi);
}
// ... other code
default:
console.error(`Missing mapper for ${value.sys.contentType.sys.id}`);
}
return undefined;
};
}

Data sources

The following index file defines the configuration for the frontastic/content and frontastic/latest-content data sources and the dynamic page handler to fetch content and product data from the CMS's API and from Composable Commerce API.

Index file for data sourcesTypeScript
import {
DataSourceConfiguration,
DataSourceContext,
DynamicPageContext,
DynamicPageSuccessResult,
ExtensionRegistry,
Request,
} from '@frontastic/extension-types';
import { Category } from '@Types/product';
import ContentApi from './apis/ContentApi';
import * as ContentActions from './actionControllers/ContentController';
import { getLocale, getPath } from './utils/Request';
import { ProductApi } from '@Commerce-commercetools/apis/ProductApi';
import { getCurrency } from '@Commerce-commercetools/utils/Request';
export default {
'data-sources': {
'frontastic/content': async (
config: DataSourceConfiguration,
context: DataSourceContext
) => {
const locale = getLocale(context.request);
const currency = getCurrency(context.request);
const contentApi = new ContentApi(context.frontasticContext, locale);
const productApi = new ProductApi(
context.frontasticContext,
locale,
currency
);
return await contentApi
.getContentBySlug(config.configuration.contentId, productApi)
.then((contentResult) => {
return !context.isPreview
? { dataSourcePayload: contentResult }
: {
dataSourcePayload: contentResult,
previewPayload: [
{
title: contentResult.title,
},
],
};
});
},
'frontastic/latest-content': async (
config: DataSourceConfiguration,
context: DataSourceContext
) => {
const locale = getLocale(context.request);
const currency = getCurrency(context.request);
const contentApi = new ContentApi(context.frontasticContext, locale);
const productApi = new ProductApi(
context.frontasticContext,
locale,
currency
);
return await contentApi
.getLatestContentItems(config.configuration.contentId, productApi)
.then((contentResult) => {
return !context.isPreview
? { dataSourcePayload: contentResult }
: {
dataSourcePayload: contentResult,
previewPayload: contentResult.map((value) => value.title),
};
});
} ,
// ... other code
},
},
actions: {
content: ContentActions,
},
'dynamic-page-handler': async (
request: Request,
context: DynamicPageContext
): Promise<DynamicPageSuccessResult | null> => {
const path = getPath(request);
if (path.startsWith('/inspiration')) {
const [_, contentId] = path.match(new RegExp('/inspiration/([^ /]+)'));
if (contentId) {
const locale = getLocale(request);
const currency = getCurrency(request);
const contentApi = new ContentApi(context.frontasticContext, locale);
const productApi = new ProductApi(
context.frontasticContext,
locale,
currency
);
return await contentApi
.getContentBySlug(contentId, productApi)
.then((result) => {
return {
dynamicPageType:
'dynamicPageType' in result
? result.dynamicPageType
: 'contentful/story',
dataSourcePayload: result,
};
});
}
return {
dynamicPageType: 'contentful/story',
dataSourcePayload: {},
};
}
return null;
},
} as ExtensionRegistry;

contentful Content Frontend component

The Frontend component imports two out-of-the-box UI components: ContentPage and Story. In this scenario, we did not create customized UI components as the out-of-the-box ones matched the rendering needs. The Frontend component consists of the following index and schema files:

Index file of the contentful Content Frontend componentTypeScript
'use client';
import React from 'react';
import { Story as StoryProps, Page as PageProps } from 'shared/types/content';
import ContentPage from '../../../../components/commercetools-ui/organisms/content/page';
import Story from '../../../../components/commercetools-ui/organisms/content/story';
import { DataSource } from '../../../../types/datasource';
import { TasticProps } from '../../types';
type ContentPageTasticProps = TasticProps<DataSource<StoryProps | PageProps>>;
const ContentfulContentTastic = ({ data }: ContentPageTasticProps) => {
if (data.data?.dataSource) {
if (data.data.dataSource.dynamicPageType === 'contentful/story') {
return <Story {...(data.data?.dataSource as StoryProps)} />;
} else {
let dataSource = data.data?.dataSource;
if (!dataSource.slots) {
dataSource = {
id: '',
title: '',
slug: '',
slots: [data.data?.dataSource],
dynamicPageType: 'content/page',
};
}
return <ContentPage {...(dataSource as PageProps)} />;
}
}
return null;
};
export default ContentfulContentTastic;
Schema file of the contentful Content Frontend componentjson
{
"name": "contentful Content",
"category": "Content",
"icon": "bookmark",
"schema": [
{
"name": "Configuration",
"fields": [
{
"label": "Data",
"field": "data",
"type": "dataSource",
"dataSourceType": "frontastic/content",
"translatable": true
}
]
}
],
"tasticType": "commercetools/ui/content/content-page"
}

contentful Content List Frontend component

The Frontend component imports the ContentSlider out-of-the-box UI component. In this scenario, we did not develop customized UI components as the out-of-the-box one matched the rendering needs. The Frontend component consists of the following index and schema files:

Index file of the contentful Content List Frontend componentTypeScript
'use client';
import React from 'react';
import { Story } from 'shared/types/content';
import ContentSlider from 'components/commercetools-ui/organisms/content-slider';
import { mapContentful } from '../../../../helpers/content';
import { DataSource } from '../../../../types/datasource';
import { TasticProps } from '../../types';
export function notEmpty<TValue>(
value: TValue | null | undefined
): value is TValue {
return value !== null && value !== undefined;
}
export type SliderProps = {
title?: string;
subtitle?: string;
};
type ContentPageTasticProps = TasticProps<
DataSource<Array<Story>> & SliderProps
>;
const ContentfulContentListTastic = ({ data }: ContentPageTasticProps) => {
return (
<ContentSlider
title={data.title}
subtitle={data.subtitle}
slides={data?.data?.dataSource?.map(mapContentful).filter(notEmpty) || []}
/>
);
};
export default ContentfulContentListTastic;
Schema file of the contentful Content List Frontend componentjson
{
"name": "contentful Content List",
"category": "Content",
"icon": "bookmark",
"schema": [
{
"name": "Head",
"fields": [
{
"label": "Title",
"field": "title",
"type": "string"
},
{
"label": "Subtitle",
"field": "subtitle",
"type": "string"
}
]
},
{
"name": "Configuration",
"fields": [
{
"label": "Data",
"field": "data",
"type": "dataSource",
"dataSourceType": "frontastic/latest-content",
"translatable": true
}
]
}
],
"tasticType": "commercetools/ui/content/contentful-latest-content-slider"
}