Skip to content

Add a CMS

Each CMS is one package with one module. You add the module to the service that serves your content, and it answers the content queries of the GraphQL API.

CMS Package Module
Contentful @evolve-framework/contentful ContentfulModule
Storyblok @evolve-framework/storyblok StoryblokModule
Payload @evolve-framework/payload PayloadModule

All three serve one content contract: the same queries, page types, and block types, defined once in @evolve-framework/schemas. Your storefront queries pages and blocks the same way whichever CMS is behind them. See the content GraphQL reference.

Before the module can serve a page, the CMS needs a place for your content that matches what the module asks for, and credentials for the module.

  1. Create a space, and an environment in it. The module reads the environment you pass as environment.
  2. Add a locale for each locale your storefront serves, with the same code, such as nl-NL. The module asks Contentful for the request’s locale. The B2B reference site keeps its locales in Terraform, in configuration/contentful/locales.tf, each falling back to en-GB.
  3. Create the tokens: an API key, which gives the Delivery and Preview tokens, and a Content Management API token.
  4. Create the content model. See Create the content model.

Both reference sites also connect the CMS to commercetools, so that editors can pick products and categories: a commercetools API client with view_products and view_categories, in configuration/storyblok/commercetools.tf (B2C) and configuration/contentful/app_commercetools.tf (B2B). The B2B site installs the commercetools app in Contentful in the same file.

Install the package and the peers listed on its reference page. Then create the module and give it to your application.

import { ContentfulModule } from "@evolve-framework/contentful";
import { initContextValue } from "@evolve-framework/contentful/graphql/context";
import { createApplication } from "@evolve-framework/core";
import { NoopPublisher } from "@evolve-framework/core/messaging";
const app = await createApplication({
name: "cms",
module: new ContentfulModule({
config: {
spaceId: process.env.CONTENTFUL_SPACE_ID!,
environment: process.env.CONTENTFUL_ENVIRONMENT ?? "master",
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
previewToken: process.env.CONTENTFUL_PREVIEW_TOKEN!,
managementToken: process.env.CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN!,
},
messagePublisher: new NoopPublisher(),
}),
graphql: {
// The module's loaders read the request, which initContextValue leaves off.
context: (context) => ({ ...initContextValue(context), request: context.request }),
},
http: { address: { host: "0.0.0.0", port: 4004 } },
});
await app.start();

The module needs all three tokens: the Delivery API token for published content, the Preview API token for drafts, and the management token to create catalog pages.

  • config or clients. Contentful and Storyblok build their clients from config. To share the clients with other code, build them with createContentfulClients or createStoryblokClients and pass clients instead. Pass one of the two, not both.
  • One module per process. Each module keeps its clients and its mapper registry in module scope, so run one instance of it per process.
  • In a composed service. Add the module to your service’s CompositeModule instead. The Contentful and Storyblok loaders read request from the context, so your context factory must put it there.

The options of each module are on its reference page.

Contentful and Storyblok call a webhook on your service when an editor publishes. The module turns that call into a content-modified-event and publishes it through the messagePublisher you pass. The option is required. Pass a NoopPublisher to publish nothing, or the publisher for your cloud: see Publish events.

The webhook route is /webhook. Change it with webhookPath. In Contentful, add a webhook that posts entry changes to that path on your service. The B2B reference site creates it in Terraform, in configuration/contentful-service/webhook.tf:

resource "contentful_webhook" "primary" {
space_id = var.variables.contentful_space_id
name = "${var.environment}-${var.site}-content-published"
url = "https://${module.service.endpoint}/webhook"
headers = {}
topics = ["Entry.publish"]
# One webhook per environment: only entries of this one reach this service.
filters = jsonencode([
{ in : [{ "doc" : "sys.environment.sys.id" }, [var.variables.contentful_environment]] },
])
}

The module decides what the change affects:

  • For a page entry (pageContent, pageCatalog), the page’s paths.
  • For a layout entry (siteHeader, siteFooter, navigationMenu, navigationMenuItem), the layout.
  • For any other entry, the paths of the pages that link to it, found up to five levels of references deep.

It then clears its own cache and publishes the event. An entry that no page links to clears nothing and publishes nothing.

Neither webhook route checks that a call comes from the CMS: any call clears the module’s caches and can publish an event.

The event carries contentType (page or layout) and the changed paths. To act on it, for example to purge a storefront cache, see React to events.

The modules also handle events. When the commerce engine publishes product-category-created or product-category-updated, each CMS module makes sure a catalog page exists for the category. PayloadModule also deletes the page on product-category-deleted.

A page body is a list of blocks: a hero, columns, teasers, a product row. Each package ships its blocks as contentBlocks (Contentful, Storyblok, Payload). One block bundles everything one content type needs:

  • id: the Contentful content type id, the Storyblok component name, or the Payload block slug.
  • typename: the GraphQL type it maps to, for example HeroBlock.
  • mapper: the function that turns a CMS entry into that type.
  • The content model: Terraform for Contentful and Storyblok, a Payload block config for Payload.
  • groups: where an editor may place it. block is a page body, columnEntry is a column.
  • slots: what it may contain.

The storefront renders these types with @evolve-storefront/feature-content-blocks. The sets are close but not identical. For example, Storyblok declares the YoutubeVideo type but has no component for it yet.

The blocks option of each module is the complete set it serves. It defaults to contentBlocks. To add your own block or replace a built-in one, spread contentBlocks and add yours. See Add a content block.

Each block carries the Terraform for its content type. Write it to a directory and apply it with Terraform:

evolve tf-export terraform/contentful

evolve tf-export writes one block_<id>.tf per block and a block_locals.tf. That file holds the whitelists of which content types fit in which field, derived from the blocks’ groups. The files contain resources and locals only. Define the provider, the backend, and these locals yourself:

locals {
contentful_space_id = var.contentful_space_id
contentful_environment = var.contentful_environment
}

tf-export writes the built-in set only. It is generated output: run it again when the set changes instead of editing the files. For a set with your own blocks, such as the quote block from Add a content block, call writeTerraform with your array instead:

import { contentBlocks, writeTerraform } from "@evolve-framework/contentful";
writeTerraform([...contentBlocks, quote], "terraform/contentful");

To change a built-in block beyond its mapper, eject it. You get its source in your project, and you own it from then on.

evolve eject usp-list

evolve eject takes the block’s folder names. Run it without names to list them, or pass --all.

  • The block’s TypeScript lands in src/blocks/<name>/. Change it with --dest.
  • Its Terraform lands in terraform/<cms>/block_<id>.tf. Change it with --tf-dest. The ejected block no longer carries Terraform: you edit the .tf file directly.

Then register your copy. A block with the same id as a built-in replaces it:

import { contentBlocks, ContentfulModule } from "@evolve-framework/contentful";
import { uspList } from "./blocks/usp-list/index.ts";
new ContentfulModule({
// ...config and messagePublisher
blocks: [...contentBlocks, uspList],
});

Contentful maps a content page’s hero field with the built-in mapper. A replaced hero block changes only the heroes placed in a page body.

To update an ejected block, run eject again. It overwrites your copy with the new version. Merge your changes back with git diff.

With more than one CMS package installed, name the package: evolve --package @evolve-framework/storyblok eject usp-list. Payload has no eject or tf-export command.

Every content query takes version: DRAFT to read unpublished content, for example page(path: "about", version: DRAFT).

  • Contentful reads drafts through the Preview API, with your previewToken.
  • Storyblok reads the draft version of the story. It also accepts the storeContentPreview mutation: the storefront sends the story the editor is changing, and gets an id back. A query with that previewId renders the story. The module keeps stored stories in the memory of the service process.
  • Payload serves drafts natively. storeContentPreview answers with a NOT_SUPPORTED error.

In the storefront, the visual editors of Contentful and Storyblok need a preview package: @evolve-storefront/contentful-preview or @evolve-storefront/storyblok-preview. Each has an API route handler, createPreviewHandler, and a ContentPreviewProvider that refreshes the page when the editor saves.

import { createPreviewHandler } from "@evolve-storefront/contentful-preview/api-route";
export const GET = createPreviewHandler({
previewSecret: process.env.CMS_PREVIEW_SECRET ?? "",
defaultLocale: "en",
localePrefix: { en: "/en", nl: "/nl" },
});

In the space settings, set the content preview URL for page entries to this route, with three parameters:

https://www.example.com/api/preview?secret=<previewSecret>&slug={entry.fields.slug}&locale={locale}

A missing parameter is a 400, a wrong secret a 401. The handler redirects to the slug under the prefix that localePrefix gives the locale.

The editor shows your storefront in a frame, so the storefront’s frame-ancestors has to allow the CMS: https://*.storyblok.com in the B2C reference site, https://app.contentful.com in the B2B one, both in frontend/site/src/lib/secure-headers.ts. The editor itself runs on HTTPS, and the handler marks the draft-mode cookie SameSite=None and Secure, so serve the storefront over HTTPS for previews, locally too. The B2C site’s frontend/site/README.md sets up a local HTTPS proxy for this.