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.
Set up the CMS
Section titled “Set up the CMS”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.
- Create a space, and an environment in it. The module reads the
environment you pass as
environment. - 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, inconfiguration/contentful/locales.tf, each falling back toen-GB. - Create the tokens: an API key, which gives the Delivery and Preview tokens, and a Content Management API token.
- Create the content model. See Create the content model.
- Create a space. The module calls the
euregion by default; for a space in another region, setregioninconfig(us,ap,ca, orcn). - Set up the languages. The module asks for the request’s locale, such as
nl-NL, and falls back to its language,nl. A story’s own slug belongs to the space’s default language, so setdefaultLanguageinconfigto that language’s locale. - Create the folders the module reads from:
category, for catalog pages. Its folder id iscategoryFolderId. The module looks a catalog page up undercategory/, and refuses to create catalog pages in a folder with another slug.snippets, for content snippets.
- Create one story each for the site layout, with the content types
siteHeader,siteFooter, andnavigationMenu. The module reads the first story of each type. - Create the tokens. The module reads published content and drafts with the
same
accessToken, so use a token with preview access. ThemanagementTokenis only for creating catalog pages. - Create the components. See Create the content model.
There is no space to set up. Payload keeps its content in your own database, and the content model is in your Payload config. Run the Payload admin app against the database your service reads.
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.
Add the module to a service
Section titled “Add the module to a service”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.
import { createApplication } from "@evolve-framework/core";import { cache } from "@evolve-framework/core/cache";import { NoopPublisher } from "@evolve-framework/core/messaging";import { StoryblokModule } from "@evolve-framework/storyblok";import { initContextValue } from "@evolve-framework/storyblok/graphql/context";
await cache.configure({ url: process.env.REDIS_URL });
const app = await createApplication({ name: "cms", module: new StoryblokModule({ config: { accessToken: process.env.STORYBLOK_ACCESS_TOKEN!, managementToken: process.env.STORYBLOK_MANAGEMENT_TOKEN, spaceId: Number(process.env.STORYBLOK_SPACE_ID), categoryFolderId: Number(process.env.STORYBLOK_CATEGORY_FOLDER_ID), }, siteConfigs: [ { country: "NL", languages: ["nl", "en"] }, { country: "BE", languages: ["nl", "fr"] }, ], messagePublisher: new NoopPublisher(), }), graphql: { context: initContextValue }, http: { address: { host: "0.0.0.0", port: 4004 } },});
await app.start();categoryFolderIdis the folder the module creates catalog pages in. That also needs themanagementToken.siteConfigslists the languages of each country. The module computes the translated paths of a page from it. Without it, pages have no translated paths.defaultLanguageinconfigmust be the space’s default language. It defaults toen-GB.- The page cache uses the shared
cache. Without a URL, caching is off. See The shared cache.
import { createApplication } from "@evolve-framework/core";import { PayloadModule } from "@evolve-framework/payload";import { initContextValue } from "@evolve-framework/payload/graphql/context";import config from "./payload.config.ts";
const app = await createApplication({ name: "cms", module: new PayloadModule({ config }), graphql: { context: initContextValue }, http: { address: { host: "0.0.0.0", port: 4004 } },});
await app.start();config is your Payload config, the same file the Payload admin app loads.
Add the evolveCms plugin to it.
The plugin registers the block configs and the pages and media collections
the module reads.
import { evolveCms } from "@evolve-framework/payload/config";import { buildConfig } from "payload";
export default buildConfig({ // ...your database adapter, editor, and secret plugins: [...evolveCms()],});The module does not call Payload over HTTP. It starts Payload’s Local API in
your service and reads the database the admin app writes to, so it needs no
API key. Content is scoped to a tenant per store by default. If you set
multiTenant: false on evolveCms(), set it on PayloadModule too.
configorclients. Contentful and Storyblok build their clients fromconfig. To share the clients with other code, build them withcreateContentfulClientsorcreateStoryblokClientsand passclientsinstead. 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
CompositeModuleinstead. The Contentful and Storyblok loaders readrequestfrom the context, so your context factory must put it there.
The options of each module are on its reference page.
Publish content changes
Section titled “Publish content changes”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.
The webhook route is /api/webhook. Change it with webhookPath. In
Storyblok, point the space’s story webhook at that path on your service, such
as the one for published stories. The module reads the changed paths from the
full_slug in the call.
For each call, the module flushes its Storyblok client cache, its page cache,
and its cache version. Then it publishes the story’s full_slug paths, with
the locale segment normalized (nl-nl/home becomes nl-NL/home). The event’s
origin is cms unless you set origin.
PayloadModule has no webhook route and takes no publisher. The Payload app
publishes content-modified-event itself when you pass contentEvents to
evolveCms() in your Payload config: on publishing, unpublishing, or deleting
a published page, catalog page, snippet, header, footer, or navigation, never
on a draft save.
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.
The content blocks
Section titled “The content blocks”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 exampleHeroBlock.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.blockis a page body,columnEntryis 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.
Create the content model
Section titled “Create the content model”Each block carries the Terraform for its content type. Write it to a directory and apply it with Terraform:
evolve tf-export terraform/contentfulevolve 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}Each block carries the Terraform for its component. Write it to a directory and apply it with Terraform:
evolve tf-export terraform/storyblokevolve tf-export writes one
block_<component>.tf per block and a block_locals.tf. That file holds the
component_whitelist lists of which components fit in which field, derived
from the blocks’ groups. The files contain resources and locals only.
Define the provider, the backend, the component groups, and the locals the
blocks reference yourself: storyblok_space_id,
storyblok_component_groups (with content, navigation, and page), and
storyblok_category_field_options for the category picker.
The evolveCms() plugin adds each block’s config to your Payload config, so
the content model is in your code. There is no Terraform. Pass the same
blocks array to evolveCms() and to PayloadModule.
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");Take ownership of a built-in block
Section titled “Take ownership of a built-in block”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-listevolve 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.tffile 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.
Preview drafts
Section titled “Preview drafts”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
storeContentPreviewmutation: the storefront sends the story the editor is changing, and gets an id back. A query with thatpreviewIdrenders the story. The module keeps stored stories in the memory of the service process. - Payload serves drafts natively.
storeContentPreviewanswers with aNOT_SUPPORTEDerror.
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.
import { createPreviewHandler } from "@evolve-storefront/storyblok-preview/api-route";
export const GET = createPreviewHandler({ previewSecret: process.env.CMS_PREVIEW_SECRET ?? "", previewToken: process.env.STORYBLOK_PREVIEW_TOKEN ?? "", defaultLocale: "en", localePrefix: { en: "/en", nl: "/nl" },});In the space settings, set the preview URL of the visual editor to this
route, ending in an empty slug:
https://www.example.com/api/preview?secret_token=<previewSecret>&slug=Storyblok appends the story’s path and signs the call with _storyblok_tk
parameters. The handler checks secret_token against previewSecret, and the
signature against previewToken, the space’s preview token. A signature older
than an hour fails. With NODE_ENV set to development, failed checks are
let through.
There is no storefront preview package for Payload. Query with
version: DRAFT to show drafts.
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.

