---
schema_version: "1"
id: 3b83c33819d00fb8ae2f4a9634562883a1ee715e144e8a91fb47b466ae075b55
title: Customize a Page with Template Extensibility
collection: developer
version: current
locale: en-us
portal_url: https://docs.salesforce.com/developer/current/en-us/commerce-cloud/pwa-kit-managed-runtime/guides/customize-a-page.md
canonical_url: https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/customize-a-page.html
source_site: developer.salesforce.com
content_type: documentation
content_hash: 3a1da8cb233b2c72010e865f63e7d6130b4c0b000fa1617ca381918a22cad212
products:
  - Composable Storefront
  - Salesforce APIs
---
# Customize a Page with Template Extensibility

Let’s imagine that you want to add a component to a page and there’s no existing component that can be overridden. Page-level extensibility solves this problem. In this tutorial, we’ll use [template extensibility](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/template-extensibility.html) to customize a product detail page (PDP) by adding a component to the page. In this example, we’ll add a promotional banner to the PDP.

For more guidance, see this documentation: [Best Practices](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/template-extensibility.md#template-extensibility-best-practices) and [Troubleshoot](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/template-extensibility.md#troubleshoot-template-extensibility).

Before running the commands in this tutorial, replace any placeholders with actual values. Placeholders are formatted like this: `$PLACEHOLDER`.

**tip:** Use the headings on the right side of the page to move through this tutorial.

## Prerequisite

To complete this tutorial, do either of these:

1. Use a project created with PWA Kit version 3.x.

   or

2. If you don’t have a project, create a PWA Kit project by running:

   `npx @salesforce/pwa-kit-create-app@latest ——outputDir $PATH/TO/NEW/LOCAL/PROJECT`


## 1. Create Files to Override Defaults

<style>
ul.no-bullets {
    list-style-type: "a. ";
}
</style>

<ul type="a">
  <li> Under <code>overrides/app/components</code>, create a folder called <code>banner.</code></li>
  <li> In the <code>banner</code> folder, create a file called <code>index.jsx.</code></li>
  <li> Under <code>overrides/app/pages</code>, create a folder called <code>product-detail.</code>.</li>
  <li> In the <code>product-detail</code> folder, create a file called <code>index.jsx.</code> </li>
</ul>

The new folders and files have this structure:

![Associated screenshot](https://developer.salesforce.com/docs/media/pwa-kit-managed-runtime/banner-folder.png)

## 2. Add a Product Detail Route

Add a product detail route to the list of of routes in `overrides/app/routes.jsx`:

```jsx
const routes = [
  {
    path: "/",
    component: Home,
    exact: true,
  },
  {
    path: "/my-new-route",
    component: MyNewRoute,
  },
  // Add a product detail route.
  {
    path: "/product/:productId",
    component: ProductDetail,
  },
  ..._routes,
];
```

## 3. Create a Custom Banner

Add this code to `overrides/app/components/banner/index.jsx`:

```jsx
import React from "react";
import { Box, Flex, Text } from "@chakra-ui/react";
import Link from "@salesforce/retail-react-app/app/components/link";
const BannerWithImage = () => {
  return (
    <Link to="/category/womens" bg="teal.200">
      <Box py={4} maxH="150px" overflow="hidden">
        <Flex
          maxW="1200px"
          mx="auto"
          align="center"
          justify="center"
          position="relative"
        >
          <Text fontSize="2xl" fontWeight="bold" color="black">
            Flash Sale on Women's Clothes. Up to 50% Off.
          </Text>
        </Flex>
      </Box>
    </Link>
  );
};

export default BannerWithImage;
```

## 4. Create a Custom PDP

Add the code below to `overrides/app/pages/product-detail/index.jsx`.

We want to keep the PDP’s current layout and only add a component to the page. So we’ll reuse the [code from base template](https://github.com/SalesforceCommerceCloud/pwa-kit/blob/develop/packages/template-retail-react-app/app/pages/product-detail/index.jsx) by copying it into the newly created `index.jsx` file. Then we can customize the code as we see fit.

In this example:

- On **line 5**, we’ll import a `BannerWithImage` component from the banner folder.
- On **line 333**, we’ll add the `BannerWithImage` component at the top of the page.

```jsx
import React, { Fragment, useCallback, useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Helmet } from "react-helmet";
import { FormattedMessage, useIntl } from "react-intl";
// This is line 5. Import your banner.
import BannerWithImage from "../../components/banner";

// Components
import {
  Box,
  Button,
  Stack,
} from "@salesforce/retail-react-app/app/components/shared/ui";
import {
  useProduct,
  useCategory,
  useShopperBasketsMutation,
  useShopperCustomersMutation,
  useCustomerId,
} from "@salesforce/commerce-sdk-react";

// Hooks
import { useCurrentBasket } from "@salesforce/retail-react-app/app/hooks/use-current-basket";
import { useVariant } from "@salesforce/retail-react-app/app/hooks";
import useNavigation from "@salesforce/retail-react-app/app/hooks/use-navigation";
import useEinstein from "@salesforce/retail-react-app/app/hooks/use-einstein";
import useActiveData from "@salesforce/retail-react-app/app/hooks/use-active-data";
import { useServerContext } from "@salesforce/pwa-kit-react-sdk/ssr/universal/hooks";
// Project Components
import RecommendedProducts from "@salesforce/retail-react-app/app/components/recommended-products";
import ProductView from "@salesforce/retail-react-app/app/components/product-view";
import InformationAccordion from "@salesforce/retail-react-app/app/pages/product-detail/partials/information-accordion";

import {
  HTTPNotFound,
  HTTPError,
} from "@salesforce/pwa-kit-react-sdk/ssr/universal/errors";

// constant
import {
  API_ERROR_MESSAGE,
  EINSTEIN_RECOMMENDERS,
  MAX_CACHE_AGE,
  TOAST_ACTION_VIEW_WISHLIST,
  TOAST_MESSAGE_ADDED_TO_WISHLIST,
  TOAST_MESSAGE_ALREADY_IN_WISHLIST,
} from "@salesforce/retail-react-app/app/constants";
import { rebuildPathWithParams } from "@salesforce/retail-react-app/app/utils/url";
import { useHistory, useLocation, useParams } from "react-router-dom";
import { useToast } from "@salesforce/retail-react-app/app/hooks/use-toast";
import { useWishList } from "@salesforce/retail-react-app/app/hooks/use-wish-list";

const ProductDetail = () => {
  const { formatMessage } = useIntl();
  const history = useHistory();
  const location = useLocation();
  const einstein = useEinstein();
  const activeData = useActiveData();
  const toast = useToast();
  const navigate = useNavigation();
  const [productSetSelection, setProductSetSelection] = useState({});
  const childProductRefs = React.useRef({});
  const customerId = useCustomerId();
  /****************************** Basket *********************************/
  const { data: basket } = useCurrentBasket();
  const addItemToBasketMutation = useShopperBasketsMutation("addItemToBasket");
  const { res } = useServerContext();
  if (res) {
    res.set("Cache-Control", `s-maxage=${MAX_CACHE_AGE}`);
  }
  const isBasketLoading = !basket?.basketId;

  /*************************** Product Detail and Category ********************/
  const { productId } = useParams();
  const urlParams = new URLSearchParams(location.search);
  const {
    data: product,
    isLoading: isProductLoading,
    isError: isProductError,
    error: productError,
  } = useProduct(
    {
      parameters: {
        id: urlParams.get("pid") || productId,
        allImages: true,
      },
    },
    {
      // When shoppers select a different variant (and the app fetches the new data),
      // the old data is still rendered (and not the skeletons).
      keepPreviousData: true,
    }
  );

  // Note: Since category needs id from product detail, it can't be server side rendered atm
  // until we can do dependent query on server
  const {
    data: category,
    isError: isCategoryError,
    error: categoryError,
  } = useCategory({
    parameters: {
      id: product?.primaryCategoryId,
      levels: 1,
    },
  });

  /**************** Error Handling ****************/

  if (isProductError) {
    const errorStatus = productError?.response?.status;
    switch (errorStatus) {
      case 404:
        throw new HTTPNotFound("Product Not Found.");
      default:
        throw new HTTPError(`HTTP Error ${errorStatus} occurred.`);
    }
  }
  if (isCategoryError) {
    const errorStatus = categoryError?.response?.status;
    switch (errorStatus) {
      case 404:
        throw new HTTPNotFound("Category Not Found.");
      default:
        throw new HTTPError(`HTTP Error ${errorStatus} occurred.`);
    }
  }

  const isProductASet = product?.type.set;

  const [primaryCategory, setPrimaryCategory] = useState(category);
  const variant = useVariant(product);
  // This page uses the `primaryCategoryId` to retrieve the category data. This attribute
  // is only available on `master` products. Since a variation will be loaded once all the
  // attributes are selected (to get the correct inventory values), the category information
  // is overridden. This will allow us to keep the initial category around until a different
  // master product is loaded.
  useEffect(() => {
    if (category) {
      setPrimaryCategory(category);
    }
  }, [category]);

  /**************** Product Variant ****************/
  useEffect(() => {
    if (!variant) {
      return;
    }
    // update the variation attributes parameter on
    // the url accordingly as the variant changes
    const updatedUrl = rebuildPathWithParams(
      `${location.pathname}${location.search}`,
      {
        pid: variant?.productId,
      }
    );
    history.replace(updatedUrl);
  }, [variant]);

  /**************** Wishlist ****************/
  const { data: wishlist, isLoading: isWishlistLoading } = useWishList();
  const createCustomerProductListItem = useShopperCustomersMutation(
    "createCustomerProductListItem"
  );

  const handleAddToWishlist = (product, variant, quantity) => {
    const isItemInWishlist = wishlist?.customerProductListItems?.find(
      (i) => i.productId === variant?.productId || i.productId === product?.id
    );

    if (!isItemInWishlist) {
      createCustomerProductListItem.mutate(
        {
          parameters: {
            listId: wishlist.id,
            customerId,
          },
          body: {
            // NOTE: APi does not respect quantity, it always adds 1
            quantity,
            productId: variant?.productId || product?.id,
            public: false,
            priority: 1,
            type: "product",
          },
        },
        {
          onSuccess: () => {
            toast({
              title: formatMessage(TOAST_MESSAGE_ADDED_TO_WISHLIST, {
                quantity: 1,
              }),
              status: "success",
              action: (
                // it would be better if we could use <Button as={Link}>
                // but unfortunately the Link component is not compatible
                // with Chakra Toast, since the ToastManager is rendered via portal
                // and the toast doesn't have access to intl provider, which is a
                // requirement of the Link component.
                <Button
                  variant="link"
                  onClick={() => navigate("/account/wishlist")}
                >
                  {formatMessage(TOAST_ACTION_VIEW_WISHLIST)}
                </Button>
              ),
            });
          },
          onError: () => {
            showError();
          },
        }
      );
    } else {
      toast({
        title: formatMessage(TOAST_MESSAGE_ALREADY_IN_WISHLIST),
        status: "info",
        action: (
          <Button variant="link" onClick={() => navigate("/account/wishlist")}>
            {formatMessage(TOAST_ACTION_VIEW_WISHLIST)}
          </Button>
        ),
      });
    }
  };

  /**************** Add To Cart ****************/
  const showToast = useToast();
  const showError = () => {
    showToast({
      title: formatMessage(API_ERROR_MESSAGE),
      status: "error",
    });
  };

  const handleAddToCart = async (productSelectionValues) => {
    try {
      const productItems = productSelectionValues.map(
        ({ variant, quantity }) => ({
          productId: variant.productId,
          price: variant.price,
          quantity,
        })
      );

      await addItemToBasketMutation.mutateAsync({
        parameters: { basketId: basket.basketId },
        body: productItems,
      });

      einstein.sendAddToCart(productItems);

      // If the items were successfully added, set the return value to be used
      // by the add to cart modal.
      return productSelectionValues;
    } catch (error) {
      showError(error);
    }
  };

  /**************** Product Set Handlers ****************/
  const handleProductSetValidation = useCallback(() => {
    // Run validation for all child products. This will ensure the error
    // messages are shown.
    Object.values(childProductRefs.current).forEach(
      ({ validateOrderability }) => {
        validateOrderability({ scrollErrorIntoView: false });
      }
    );

    // Using ot state for which child products are selected, scroll to the first
    // one that isn't selected.
    const selectedProductIds = Object.keys(productSetSelection);
    const firstUnselectedProduct = product.setProducts.find(
      ({ id }) => !selectedProductIds.includes(id)
    );

    if (firstUnselectedProduct) {
      // Get the reference to the product view and scroll to it.
      const { ref } = childProductRefs.current[firstUnselectedProduct.id];

      if (ref.scrollIntoView) {
        ref.scrollIntoView({
          behavior: "smooth",
          block: "end",
        });
      }

      return false;
    }

    return true;
  }, [product, productSetSelection]);

  const handleProductSetAddToCart = () => {
    // Get all the selected products, and pass them to the addToCart handler which
    // accepts an array.
    const productSelectionValues = Object.values(productSetSelection);
    return handleAddToCart(productSelectionValues);
  };

  /**************** Einstein ****************/
  useEffect(() => {
    if (product && product.type.set) {
      einstein.sendViewProduct(product);
      const childrenProducts = product.setProducts;
      childrenProducts.map((child) => {
        try {
          einstein.sendViewProduct(child);
        } catch (err) {
          console.error(err);
        }
        activeData.sendViewProduct(category, child, "detail");
      });
    } else if (product) {
      try {
        einstein.sendViewProduct(product);
      } catch (err) {
        console.error(err);
      }
      activeData.sendViewProduct(category, product, "detail");
    }
  }, [product]);

  return (
    <Box
      className="sf-product-detail-page"
      layerStyle="page"
      data-testid="product-details-page"
    >
      <Helmet>
        <title>{product?.pageTitle}</title>
        <meta name="description" content={product?.pageDescription} />
      </Helmet>

      <Stack spacing={16}>
        // This is line 333. Add the banner component to the page.
        <BannerWithImage />
        {isProductASet ? (
          <Fragment>
            {/* Product Set: parent product */}
            <ProductView
              product={product}
              category={primaryCategory?.parentCategoryTree || []}
              addToCart={handleProductSetAddToCart}
              addToWishlist={handleAddToWishlist}
              isProductLoading={isProductLoading}
              isBasketLoading={isBasketLoading}
              isWishlistLoading={isWishlistLoading}
              validateOrderability={handleProductSetValidation}
            />

            <hr />

            {/* TODO: consider `childProduct.belongsToSet` */}
            {
              // Product Set: render the child products
              product.setProducts.map((childProduct) => (
                <Box key={childProduct.id} data-testid="child-product">
                  <ProductView
                    // Do no use an arrow function as we are manipulating the functions scope.
                    ref={function (ref) {
                      // Assign the "set" scope of the ref, this is how we access the internal
                      // validation.
                      childProductRefs.current[childProduct.id] = {
                        ref,
                        validateOrderability: this.validateOrderability,
                      };
                    }}
                    product={childProduct}
                    isProductPartOfSet={true}
                    addToCart={(variant, quantity) =>
                      handleAddToCart([
                        { product: childProduct, variant, quantity },
                      ])
                    }
                    addToWishlist={handleAddToWishlist}
                    onVariantSelected={(product, variant, quantity) => {
                      if (quantity) {
                        setProductSetSelection((previousState) => ({
                          ...previousState,
                          [product.id]: {
                            product,
                            variant,
                            quantity,
                          },
                        }));
                      } else {
                        const selections = { ...productSetSelection };
                        delete selections[product.id];
                        setProductSetSelection(selections);
                      }
                    }}
                    isProductLoading={isProductLoading}
                    isBasketLoading={isBasketLoading}
                    isWishlistLoading={isWishlistLoading}
                  />
                  <InformationAccordion product={childProduct} />

                  <Box display={["none", "none", "none", "block"]}>
                    <hr />
                  </Box>
                </Box>
              ))
            }
          </Fragment>
        ) : (
          <Fragment>
            <ProductView
              product={product}
              category={primaryCategory?.parentCategoryTree || []}
              addToCart={(variant, quantity) =>
                handleAddToCart([{ product, variant, quantity }])
              }
              addToWishlist={handleAddToWishlist}
              isProductLoading={isProductLoading}
              isBasketLoading={isBasketLoading}
              isWishlistLoading={isWishlistLoading}
            />
            <InformationAccordion product={product} />
          </Fragment>
        )}
        {/* Product Recommendations */}
        <Stack spacing={16}>
          {!isProductASet && (
            <RecommendedProducts
              title={
                <FormattedMessage
                  defaultMessage="Complete the Set"
                  id="product_detail.recommended_products.title.complete_set"
                />
              }
              recommender={EINSTEIN_RECOMMENDERS.PDP_COMPLETE_SET}
              products={[product]}
              mx={{ base: -4, md: -8, lg: 0 }}
              shouldFetch={() => product?.id}
            />
          )}
          <RecommendedProducts
            title={
              <FormattedMessage
                defaultMessage="You might also like"
                id="product_detail.recommended_products.title.might_also_like"
              />
            }
            recommender={EINSTEIN_RECOMMENDERS.PDP_MIGHT_ALSO_LIKE}
            products={[product]}
            mx={{ base: -4, md: -8, lg: 0 }}
            shouldFetch={() => product?.id}
          />

          <RecommendedProducts
            // The Recently Viewed recommender doesn't use `products`, so instead we
            // provide a key to update the recommendations on navigation.
            key={location.key}
            title={
              <FormattedMessage
                defaultMessage="Recently Viewed"
                id="product_detail.recommended_products.title.recently_viewed"
              />
            }
            recommender={EINSTEIN_RECOMMENDERS.PDP_RECENTLY_VIEWED}
            mx={{ base: -4, md: -8, lg: 0 }}
          />
        </Stack>
      </Stack>
    </Box>
  );
};

ProductDetail.getTemplateName = () => "product-detail";

ProductDetail.propTypes = {
  /**
   * The current react router match object. (Provided internally)
   */
  match: PropTypes.object,
};

export default ProductDetail;
```

## 5. Preview Your PDP

<style>
ul.no-bullets {
list-style-type: "a. ";
}
</style>

<ul type="a">
  <li> Run <code>npm start</code>.</li>
  <li> Go to <a href=http://localhost:3000/my-new-route> http://localhost:3000</a>.</li>
</ul>

Every PDP displays your customer banner as shown in the example below. In this scenario, when you click on the banner, it takes you to the women’s product listing page.

![Associated screenshot](https://developer.salesforce.com/docs/media/pwa-kit-managed-runtime/customer-banner.png)

## 6. Deploy Your Bundle

<style>
ul.no-bullets {
    list-style-type: "a. ";
}
</style>

<ul type="a">
  <li> Make sure that your project includes the <a href=https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/template-extensibility.html#required-project-file> Required Project Files</a> in the <code>overrides</code> folder.</li>
  <li><a href=https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/pushing-and-deploying-bundles.html>Deploy your bundle</a>.</li>
</ul>

## See Also

- [Template Extensibility](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/template-extensibility.html)
- [Retail React App](https://developer.salesforce.com/docs/commerce/pwa-kit-managed-runtime/guide/retail-react-app.html)
