Skip to content

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]

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:

  1. src/proxy.ts runs 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/).
  2. Next.js matches a route under app/[locale]/.
  3. When no route matches, the rewrites in next-rewrites.ts send the request to the content route. The locale root goes to the CMS page called homepage.
  4. The page queries the GraphQL API with the store context, so the backend answers in the store’s locale. A null answer 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.

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.

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 CMS
type 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 module
type 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.

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.

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.

  • ArticlePage is an article, such as a blog post or a buying guide. Its text owns the page: a title the template renders as the H1, a lead, a leadImage, a publishedAt date, the product categories it is about, a body of blocks, and up to three related articles. Its section is the overview it belongs to.
  • ArticleOverviewPage is the overview of one section of articles, such as a blog, with a heading, an intro, a featured article, and a body.
  • page returns a Page, for a route that does not know which kind of page lives on a path. Select the Page fields, and the rest per page type with an inline fragment. It is also the only query that returns an article or an overview.
  • pages lists pages, of the types you ask for: CONTENT, CATALOG, ARTICLE, or ARTICLE_OVERVIEW. The reference sites build their content sitemap from it.
  • ProductCategoryPage is 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.

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.

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.

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.

  1. Create the route under app/[locale]/(main)/, for example stores/[slug]/page.tsx.
  2. If the segment should change per language, add the route to pathnames in packages/site-config/src/i18n/config.ts.
  3. Link to it with Link from #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.