Pages and routes
The storefront has one route per kind of page. A content page and a category page live in the CMS, which finds them by path. A product lives in the commerce engine, which finds it by slug. Any URL the storefront has no route for goes to the CMS.
flowchart LR home["/nl"] --> proxy[proxy.ts<br/>locale and rewrites] about["/nl/over-ons"] --> proxy category["/nl/categorie/heren/shirts"] --> proxy product["/nl/product/oxford-shirt"] --> proxy proxy --> r1["app/[locale]/content/…<br/>contentPage"] proxy --> r2["category route<br/>catalogPage + productSearch"] proxy --> r3["product route<br/>product(slug)"] r1 --> cms[CMS] r2 --> both[CMS + engine] r3 --> engine[Engine]
From URL to page
Section titled “From URL to page”These are the routes of the reference sites, with the Dutch store as the example:
| URL | Route under app/[locale]/ |
Query |
|---|---|---|
/nl |
content/[...slug], as homepage |
contentPage(path: "homepage") |
/nl/over-ons |
content/[...slug] |
contentPage(path: "over-ons") |
/nl/categorie/heren/shirts |
category/[...slug] |
catalogPage(path: "heren/shirts") |
/nl/product/oxford-shirt |
product/[slug] |
product(slug: "oxford-shirt") |
/nl/zoeken |
search |
productSearch |
A request takes four steps:
src/proxy.tsruns the next-intl middleware. It turns the prefix (/nl) into the locale (nl-NL) and a translated segment (/categorie/) into the route’s own name (/category/).- Next.js matches a route under
app/[locale]/. - When no route matches, the rewrites in
next-rewrites.tssend the request to the content route. The locale root goes to the CMS page calledhomepage. - The page queries the GraphQL API with the store context, so the
backend answers in the store’s locale. A
nullanswer renders the 404 page.
The rewrites:
const localeSegment = `/:locale(${locales.join("|")})`;
export const rewrites: NextConfig["rewrites"] = async () => ({ afterFiles: [ { // The homepage is a CMS page like any other. source: localeSegment, destination: "/:locale/content/homepage", }, ], fallback: [ { source: `${localeSegment}/:path*`, destination: "/:locale/content/:path*", }, ],});A fallback rewrite runs only after every route has had its turn. A route you
add therefore wins over a CMS page on the same path.
The translated segments, such as /categorie/ and /warenkorb/, are the
pathnames in packages/site-config/src/i18n/config.ts. How the locale
reaches the backend is in Add a language.
Content pages
Section titled “Content pages”The content route joins the slug segments into one path, without a leading slash, and asks for the page:
const GetContentPage = graphql(` query GetContentPage( $path: String! $version: ContentVersionType! $previewID: String ) { contentPage(path: $path, previewId: $previewID, version: $version) { ...ContentPageMetadataFragment ...ContentPageFragment } }`);A content page has a hero and a body of blocks. The page renders them
through the content block registry of
feature-content-blocks; see
Add a content block.
The CMS looks the path up in the request’s locale. A page’s
translatedPaths lists its path in every locale, and the route turns them
into the page’s alternate-language links.
Category pages
Section titled “Category pages”A category page is half content, half catalog. The editor owns the copy around
the listing; the commerce engine owns which products are in it. The schema
splits CatalogPage the same way:
# Served by the CMStype CatalogPage implements Page & AbstractPage @key(fields: "id") { id: ID! name: String! heading: String! path: String! hero: HeroBlock topContent: [Block!] bottomContent: [Block!] categoryId: String # ...}
# Served by the engine's catalog moduletype CatalogPage @extends @key(fields: "id") { id: ID! categoryId: String @external productListingConfig: ProductListingConfig! @requires(fields: "categoryId")}The gateway asks the CMS for the page and its categoryId, then asks the
catalog module for productListingConfig: the category, default sort order,
facets, and prefilters. The category route then runs productSearch with
that configuration. When the category no longer exists in the engine,
productListingConfig is null and the route renders the 404 page.
sequenceDiagram participant S as Storefront participant G as Gateway participant C as CMS module participant E as Catalog module (engine) S->>G: catalogPage(path) G->>C: name, hero, topContent, categoryId C-->>G: CMS fields + categoryId G->>E: productListingConfig for categoryId E-->>G: productListingConfig, or null for a deleted category G-->>S: One joined response
You do not have to create a catalog page for every category by hand. The
Contentful, Storyblok, and Payload modules handle the engine’s
product-category-created and product-category-updated events: they create
the catalog page, or update its name and path, and leave the editor’s content
alone. Payload also removes the page on product-category-deleted. See
React to events.
Product pages
Section titled “Product pages”A product has no page type. The product route asks for the product by slug:
query GetProductDetailPage($slug: String!, $selectedSku: String) { product(slug: $slug) { ...ProductDetailPageFragment }}The sku query parameter selects a variant, as in
/nl/product/oxford-shirt?sku=OX-42. Without it, the API returns the
product’s primary variant.
Page types in the schema
Section titled “Page types in the schema”The page types are part of
@evolve-framework/schemas, the contract
every engine and CMS serves. Which page types, blocks, and fields each CMS
supports is in Content model.
| Type | Served by | Query | Reference sites |
|---|---|---|---|
ContentPage |
CMS | contentPage, page |
content/[...slug] |
CatalogPage |
CMS, plus the engine for the listing | catalogPage, page |
category/[...slug] |
ArticlePage |
CMS | page |
Not used |
ArticleOverviewPage |
CMS | page |
Not used |
ProductCategoryPage |
Engine only | productCategoryPageByPath |
Not used |
Product |
Engine | product |
product/[slug] |
Every page type the CMS serves implements the Page interface: id, name,
heading, path, translatedPaths, breadcrumbs, and meta. heading is
the text of the page’s one H1. AbstractPage has the same fields and is
deprecated: every page type still implements it, so a query written against it
keeps working until a later release removes it. Select Page in new code.
ArticlePageis an article, such as a blog post or a buying guide. Its text owns the page: atitlethe template renders as the H1, alead, aleadImage, apublishedAtdate, the productcategoriesit is about, abodyof blocks, and up to threerelatedarticles. Itssectionis the overview it belongs to.ArticleOverviewPageis the overview of one section of articles, such as a blog, with aheading, anintro, afeaturedarticle, and abody.pagereturns aPage, for a route that does not know which kind of page lives on a path. Select thePagefields, and the rest per page type with an inline fragment. It is also the only query that returns an article or an overview.pageslists pages, of thetypesyou ask for:CONTENT,CATALOG,ARTICLE, orARTICLE_OVERVIEW. The reference sites build their content sitemap from it.ProductCategoryPageis a category page that comes from the engine alone, without a CMS. Use it when the engine’s category tree should drive the page and editors add no content.
Where each CMS keeps the path
Section titled “Where each CMS keeps the path”All three CMS packages answer the same queries. They differ in where they look for the path:
| CMS | Content page | Catalog page | Home |
|---|---|---|---|
| Contentful | A pageContent entry, by its path field |
A pageCatalog entry, by its path field |
homepage |
| Storyblok | The story at the path | The story at category/<path> |
homepage |
| Payload | The pages collection, by path, scoped to the store |
The catalog-pages collection |
/homepage, or the module’s homePath option |
Articles and overviews are content types of their own: pageArticle and
pageArticleOverview entries in Contentful, article_page and
article_overview_page stories in Storyblok, and the article-pages and
article-overview-pages collections in Payload. page looks them up by path
in all three.
Payload stores every path with a leading slash and adds one to the path it is asked for, so the storefront sends the same path to every CMS.
Preview
Section titled “Preview”When Next.js draft mode is on, the content route asks for
version: DRAFT with the preview id from the preview cookie, and skips the
cache. The CMS’s preview URL points at app/api/preview/route.ts, which
switches draft mode on and redirects to the page.
| CMS | Storefront package |
|---|---|
| Contentful | @evolve-storefront/contentful-preview |
| Storyblok | @evolve-storefront/storyblok-preview |
| Payload | None. A route of your own has to switch on draft mode; the content route then asks for version: DRAFT. |
The reference sites preview content pages only: the category route does not
pass version or a preview id. The full setup is in
Add a CMS.
Caching
Section titled “Caching”For a visitor who is not signed in, the content route caches the page for
15 minutes and tags it with its path. When an editor publishes in Contentful
or Storyblok, the CMS module publishes a content-modified-event with the
changed paths. Payload publishes the same event 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. Deliver the event to the storefront’s
app/api/events/route.ts and it revalidates those paths. See
Add a CMS.
Add a route of your own
Section titled “Add a route of your own”- Create the route under
app/[locale]/(main)/, for examplestores/[slug]/page.tsx. - If the segment should change per language, add the route to
pathnamesinpackages/site-config/src/i18n/config.ts. - Link to it with
Linkfrom#lib/i18n/navigation.ts, so the link gets the right prefix and segment for the locale.
The route takes over the path from the CMS. A CMS page with the same path is no longer reachable.

