A Category Landing Page is a special type of Category Page. In Magento, a Merchant is able to set special content for a specific category. Take a look at this sample Magento Luma Category Landing Page:

How does it work?
In the Magento category page configuration, there is a config field called Display Mode and there are three available options:
- Products only
- Static block only
- Static block and products

If you select the ‘Static block only’ option and set up a static block for the category, you will see your static block on the frontend for the Category, and this is exactly what Category Landing Page means.
Does PWA Studio supports Category Landing Pages?
no.
No.
No.

Is it possible to add support for CLP in PWA Studio?
If you read my blog….
Definitely YES.

Let’s do this!
Install PWA Studio
The very first thing that we need to do is create a new instance of PWA Studio.
$ yarn create @magento/pwa $ cd <directory where PWA Studio has been installed $ yarn buildpack create-custom-origin ./
Also, we need to have our own Magento 2 instance. This time I am using Magento 2.3.5 with sample data installed. If you don’t have your own local Magento 2 installation, I recommend you use Mark Shust’s Magento Docker.
People often ask me:
What is the best way to install Magento in the local environment?
Then I tell them:
Use Mark’s Docker – it’s the best

Add module base files
Now we will add the base files for our module. If you have never heard of the PWA Studio Extensibility Framework, check one of my recent articles.
Directory for the module and empty index.js file
We will keep our modules in the ‘src’ directory and link it using the Yarn link feature. This is a good way to develop new PWA Studio plugins/modules.
$ mkdir -p @marcinkwiatkowski/category-landing-page $ touch @marcinkwiatkowski/category-landing-page/index.js
Package.json file
{ "name": "@marcinkwiatkowski/category-landing-page", "version": "1.0.0", "description": "PWA Studio Plugin which adding support for Category Landing Pages", "author": "Marcin Kwiatkowski <contact@frodigo.com>", "license": "MIT", "pwa-studio": { "targets": { "intercept": "intercept.js" } }, "peerDependencies": { "@magento/pwa-buildpack": "^6.0.0", "@magento/venia-ui": "*", "react": "~16.9.0", "react-router-dom": "^5.1.0" } }
intercept.js file
const componentOverrideMapping = require('./componentOverrideMapping'); const moduleOverridePlugin = require('./moduleOverrideWebpackPlugin'); module.exports = targets => { targets.of('@magento/pwa-buildpack').specialFeatures.tap(flags => { flags[targets.name] = {esModules: true}; }); targets.of('@magento/pwa-buildpack').webpackCompiler.tap(compiler => { new moduleOverridePlugin(componentOverrideMapping).apply(compiler); }) }
This file lets PWA Studio know that we will use ES Modules here, and which files we will overwrite.
ModuleOverrideWebpackPlugin.js file
const path = require('path'); const glob = require('glob'); module.exports = class NormalModuleOverridePlugin { constructor(moduleOverrideMap) { this.name = 'NormalModuleOverridePlugin'; this.moduleOverrideMap = moduleOverrideMap; } requireResolveIfCan(id, options = undefined) { try { return require.resolve(id, options); } catch (e) { return undefined; } } resolveModulePath(context, request) { const filePathWithoutExtension = path.resolve(context, request); const files = glob.sync(`${filePathWithoutExtension}@(|.*)`); if (files.length === 0) { throw new Error(`There is no file '${filePathWithoutExtension}'`); } if (files.length > 1) { throw new Error( `There is more than one file '${filePathWithoutExtension}'` ); } return require.resolve(files[0]); } resolveModuleOverrideMap(context, map) { return Object.keys(map).reduce( (result, x) => ({ ...result, [require.resolve(x)]: this.requireResolveIfCan(map[x]) || this.resolveModulePath(context, map[x]), }), {} ); } apply(compiler) { if (Object.keys(this.moduleOverrideMap).length === 0) { return; } const moduleMap = this.resolveModuleOverrideMap( compiler.context, this.moduleOverrideMap ); compiler.hooks.normalModuleFactory.tap(this.name, (nmf) => { nmf.hooks.beforeResolve.tap(this.name, (resolve) => { if (!resolve) { return; } const moduleToReplace = this.requireResolveIfCan(resolve.request, { paths: [resolve.context], }); if (moduleToReplace && moduleMap[moduleToReplace]) { resolve.request = moduleMap[moduleToReplace]; } return resolve; }); }); } };
ComponentOverrideMappings.js file
module.exports = componentOverride = { };
Our module is scaffolded, so now we are going to link it as a dependency in our PWA Studio instance. Please add this into your package.json file in PWA Studio root:
"dependencies": { "@marcinkwiatkowski/category-landing-page": "link:<absolute_path_of_your_module>" },
Overwrite getCategory GraphQL query
We need to extend the getCategory GraphQL query and add two fields there:
- display_mode – the display mode selected by the Administrator in the admin panel
- landing_page – the ID of the static block which is selected for the current category. If no static block is selected, this value will be null.

Create the file src/@marcinkwiatkowski/category-landing-page/lib/queries/getCategory.graphql:
query category( $id: Int! $pageSize: Int! $currentPage: Int! $onServer: Boolean! $filters: ProductAttributeFilterInput! $sort: ProductAttributeSortInput ) { category(id: $id) { id description name product_count meta_title @include(if: $onServer) meta_keywords @include(if: $onServer) meta_description display_mode landing_page } products( pageSize: $pageSize currentPage: $currentPage filter: $filters sort: $sort ) { items { # Once graphql-ce/1027 is resolved, use a ProductDetails fragment here instead. __typename description { html } id media_gallery_entries { id label position disabled file } meta_title @include(if: $onServer) meta_keyword @include(if: $onServer) meta_description name price { regularPrice { amount { currency value } } } sku small_image { url } url_key ... on ConfigurableProduct { configurable_options { attribute_code attribute_id id label values { default_label label store_label use_default_value value_index swatch_data { ... on ImageSwatchData { thumbnail } value } } } variants { attributes { code value_index } product { id media_gallery_entries { id disabled file label position } sku stock_status } } } } page_info { total_pages } total_count } }
Add a mapping to src/@marcinkwiatkowski/category-landing-page/componentOverrideMappings.js
module.exports = componentOverride = { [`@magento/venia-ui/lib/queries/getCategory.graphql`]: './lib/queries/getCategory.graphql', };
Our extended query should return the following result:

You can see that display_mode equals “PAGE” which means that ‘static block mode only’ is selected.
Overwrite categoryContent Component
Now it’s time to modify the categoryContent component. We need to do a few things here:
- check which display mode is selected
- if display_mode equals PAGE, the filters toolbar will be hidden, and the static block will be displayed
Add file src/@marcinkwiatkowski/category-landing-page/lib/RootComponents/Category/categoryContent.js
import React, { Fragment, Suspense } from 'react'; import { array, shape, string } from 'prop-types'; import RichContent from '@magento/venia-ui/lib/components/RichContent'; import { useCategoryContent } from '@magento/peregrine/lib/talons/RootComponents/Category'; import { useStaticBlockOnly } from './useStaticBlockOnly'; import NoProductsFound from '@magento/venia-ui/lib/RootComponents/Category/NoProductsFound'; import { mergeClasses } from '@magento/venia-ui/lib/classify'; import { Title } from '@magento/venia-ui/lib/components/Head'; import Breadcrumbs from '@magento/venia-ui/lib/components/Breadcrumbs'; import Gallery from '@magento/venia-ui/lib/components/Gallery'; import Pagination from '@magento/venia-ui/lib/components/Pagination'; import RichText from '@magento/venia-ui/lib/components/RichText'; import defaultClasses from '@magento/venia-ui/lib/RootComponents/Category/category.css'; const FilterModal = React.lazy(() => import('@magento/venia-ui/lib/components/FilterModal')); import GET_PRODUCT_FILTERS_BY_CATEGORY from '@magento/venia-ui/lib/queries/getProductFiltersByCategory.graphql'; import {STATIC_BLOCK_ONLY_DISPLAY_MODE} from "../../constants/category"; const CategoryContent = props => { const { categoryId, data, pageControl, sortProps } = props; const [currentSort] = sortProps; const talonProps = useCategoryContent({ categoryId, data, queries: { getProductFiltersByCategory: GET_PRODUCT_FILTERS_BY_CATEGORY } }); const { categoryName, categoryDescription, filters, handleLoadFilters, handleOpenFilters, items, pageTitle, totalPagesFromData } = talonProps; const classes = mergeClasses(defaultClasses, props.classes); const isStaticBlockMode = data.category.display_mode === STATIC_BLOCK_ONLY_DISPLAY_MODE; const header = !isStaticBlockMode && filters ? ( <Fragment> <div className={classes.headerButtons}> <button className={classes.filterButton} onClick={handleOpenFilters} onFocus={handleLoadFilters} onMouseOver={handleLoadFilters} type="button" > {'Filter'} </button> <ProductSort sortProps={sortProps} /> </div> <div className={classes.sortContainer}> {'Items sorted by '} <span className={classes.sortText}>{currentSort.sortText}</span> </div> </Fragment> ) : null; // If you want to defer the loading of the FilterModal until user interaction // (hover, focus, click), simply add the talon's `loadFilters` prop as // part of the conditional here. const modal = filters ? <FilterModal filters={filters} /> : null; const categoryDescriptionElement = categoryDescription ? ( <RichContent html={categoryDescription} /> ) : null; let content; if (isStaticBlockMode) { const staticBlockOnly = useStaticBlockOnly({ blockId: data.category.landing_page }); content = ( <Fragment> <RichText content={staticBlockOnly.content} /> </Fragment> ) } else { content = totalPagesFromData === 0 ? ( <NoProductsFound categoryId={categoryId} /> ) : ( <Fragment> <section className={classes.gallery}> <Gallery items={items} /> </section> <div className={classes.pagination}> <Pagination pageControl={pageControl} /> </div> </Fragment> ); } return ( <Fragment> <Breadcrumbs categoryId={categoryId} /> <Title>{pageTitle}</Title> <article className={classes.root}> <h1 className={classes.title}> <div className={classes.categoryTitle}>{categoryName}</div> </h1> {categoryDescriptionElement} {header} {content} <Suspense fallback={null}>{modal}</Suspense> </article> </Fragment> ); }; export default CategoryContent; CategoryContent.propTypes = { classes: shape({ filterContainer: string, gallery: string, headerButtons: string, pagination: string, root: string, title: string }), // sortProps contains the following structure: // [{sortDirection: string, sortAttribute: string, sortText: string}, // React.Dispatch<React.SetStateAction<{sortDirection: string, sortAttribute: string, sortText: string}] sortProps: array };
Now I will show you the changes in comparison with the original file. First of all, we need to change import declarations and correct the paths:

Then we will add two new imports:

Here is the logic for the filters bar:

The last thing is the logic for displaying static block content if the page is in ‘static block only’ display mode.

Add useStatickBlockOnly hook
Create the file src/@marcinkwiatkowski/category-landing-page/lib/RootComponents/Category/useStaticBlockOnly.js
import { useQuery } from '@apollo/react-hooks'; import GET_CMS_BLOCKS from '@magento/venia-ui/lib/queries/getCmsBlocks.graphql'; /** * Get static block content for category. * * @param {object} props * @returns {{staticBlockContent: string}} */ export const useStaticBlockOnly = props => { const { blockId } = props; // TODO: add error handling const { data } = useQuery(GET_CMS_BLOCKS, { variables: { identifiers: [blockId] } }); const content = (data && data.cmsBlocks && data.cmsBlocks.items) ? data.cmsBlocks.items[0].content : '' return { content } }
On Line 15 there is some homework for you – adding error handling! 🙂
Now we will add the constant STATIC_BLOCK_ONLY_DISPLAY_MODE. Please create the filesrc/@marcinkwiatkowski/category-landing-page/lib/constants/category with following content:
export const STATIC_BLOCK_ONLY_DISPLAY_MODE = 'PAGE';
The last thing we need to do is to add a new mapping for the categoryContent component:
module.exports = componentOverride = { [`@magento/venia-ui/lib/queries/getCategory.graphql`]: './lib/queries/getCategory.graphql', ['@magento/venia-ui/lib/RootComponents/Category/categoryContent.js']: './lib/RootComponents/Category/categoryContent.js' };
See the results of our awesome work!
After the modifications, I can see the working category landing page in PWA Studio!

As you can see, the Page works but there are missing styles. A new content renderer is probably needed here, and I think this is quite a good subject for the next article.
Summary
This time we added support for Category Landing Pages to PWA Studio Storefront. As you can see, the PWA Studio Extensibility framework is really powerful, and thanks to this we can easily extend PWA Studio with new features.
Source code
The source code for this tutorial is available on my Github.

WRITTEN BY
Marcin Kwiatkowski
Certified Magento Full Stack Developer who is passionate about Magento. He has 7 years of professional Software engineering experience. Privately, husband, father, and DIY enthusiast.
Recent Comments