Adding Custom Attributes in Medusa.js (Part 2 - UI)

By Viktor Holik

Featured image

Welcome back to the second part of our journey into Medusa.js! In the first part, we learned how to add plugin in our Medusa.js backend and add attributes. If you missed that, go check it out.

Since that part I developed new feature — range attribute. Now, let’s dive into the fun part — making our app look even better.

In this article, we’re going to focus on adding some neat filters to the user interface. For this, we’ll be using Next.js 13.5 with page routing and the latest Medusa backend.

Enhancing Attributes

To begin, we’ll enhance our admin dashboard by incorporating additional attributes such as “style,” “on sale,” and “height.” These attributes act as markers to better categorize our products.

style attributes
height attribute
on sale attribute

Once added, we’ll navigate to the products section to apply these markers.

Upon inspecting the /store/products route using Postman, you’ll encounter a response resembling the following (I’ve kept it concise for clarity):

 {
    "products": [
        {
            "id": "prod_01HER131GM72MNSC4BQ7QXNYDP",
            "title": "Cap",
            // attribute_values are single, boolean and multiple type attributes
            "attribute_values": [
                {
                    "id": "attr_val_01HEQS1GY28ZQ4R0P2BTJG55VG",
                    "value": "Streetwear",
                    "attribute": {
                        "id": "attr_01HEQS1GY2353DC4FT8XK9R8DY",
                        "name": "Style",
                        "type": "single"
                    }
                }
            ],
            // int_attributes_values are range attributes
            "int_attribute_values": [
                {
                    "id": "int_attr_val_01HER14ZKFG2TVET62G8F1V05G",
                    "value": 20,
                    "attribute": {
                        "id": "attr_01HEQV5KH63GDDN9G7HXR1XE7V",
                        "name": "Height"
                    }
                }
            ]
        },
        {
            "id": "prod_01HER110FNS3RTPNKG3WMGNPBS",
            "title": "Hoodie",
            "attribute_values": [
                {
                    "id": "attr_val_01HEQS1GY2W21C6W4NW26VNNQB",
                    "value": "Vintage",
                    "attribute": {
                        "id": "attr_01HEQS1GY2353DC4FT8XK9R8DY",
                        "name": "Style",
                        "type": "single"
                    }
                }
            ],
            "int_attribute_values": [
                {
                    "id": "int_attr_val_01HER159V9B0HEEFSBGNWT0X12",
                    "value": 70,
                    "attribute": {
                        "id": "attr_01HEQV5KH63GDDN9G7HXR1XE7V",
                        "name": "Height"
                    }
                }
            ]
        },
        {
            "id": "prod_01HEQV4Z3D09KZCME3Y3WECNF8",
            "title": "White t-shirt",
            "attribute_values": [
                {
                    "id": "attr_val_01HEQS72F71B018W3G8J8MG340",
                    "value": "On sale",
                    "attribute": {
                        "id": "attr_01HEQS72F7KDQCTEVAQ0EG5GS8",
                        "name": "On sale",
                        "type": "boolean"
                    }
                },
                {
                    "id": "attr_val_01HEQS1GY28ZQ4R0P2BTJG55VG",
                    "value": "Streetwear",
                    "attribute": {
                        "id": "attr_01HEQS1GY2353DC4FT8XK9R8DY",
                        "name": "Style",
                        "type": "single"
                    }
                }
            ]
        }
    ]
}

Now, let’s focus on configuring our Next.js application. In this guide, we’ll prioritize functionality over aesthetics, as the styling has already been addressed.

custom attribute 2

To optimize our application, we’ll establish a filters context to manage the application’s state efficiently.

import React from "react";

interface ProductFiltersContextProps {
  attributes?: Record<string, string[]> | null;
  intAttributes?: Record<string, number[]> | null;
  setAttributes?: React.Dispatch<React.SetStateAction<Record<string, string[]> | null>>
  setIntAttributes?: React.Dispatch<
    React.SetStateAction<Record<string, number[]> | null | undefined>
  >;
}

const ProductFiltersContext = React.createContext<ProductFiltersContextProps>(
  {}
);


interface ProductFiltersProviderProps {
  initialValues: ProductFiltersContextProps;
  children: React.ReactNode;
}

export const ProductFiltersProvider = ({
  children,
  initialValues,
}: ProductFiltersProviderProps) => {
  const {
    attributes: initialAttributes,
    intAttributes: initialIntAttributes, // Range attributes
  } = initialValues;

  const [intAttributes, setIntAttributes] =
    React.useState<Record<string, number[]>>(initialIntAttributes ?? {});

  const [attributes, setAttributes] = React.useState<Record<
    string,
    string[]
  > | null>(initialAttributes ?? {});

  return (
    <ProductFiltersContext.Provider
      value={{
        attributes,
        setAttributes,
        intAttributes,
        setIntAttributes,
      }}
    >
      {children}
    </ProductFiltersContext.Provider>
  );
};

export const useProductFilters = () => {
  const context = React.useContext(ProductFiltersContext);

  return context;
};

Here is attribute filters component

export const AttributeFilters = React.memo((props: ProductFiltersProps) => {
  const { className } = props;
  const {
    setAttributes,
    attributes,
    intAttributes,
    setIntAttributes,
  } = useProductFilters(); 

  // Custom hook to fetch "/store/attributes" route
  const { attributes: customAttributes } = useAttributes();

  return (
    <VStack
      className={classnames(cls.AttributeFilters, {}, [className])}
      gap="32"
    >
      {customAttributes?.map((attribute) => {
        if (attribute.type === "boolean") {
          const checked = attributes?.[attribute.handle] ?? false;

          return (
            <Checkbox
              key={attribute.id}
              name={attribute.name}
              checked={!!checked}
              onChange={() => {
                if (checked) {
                  const newAttributes = { ...attributes };
                  delete newAttributes[attribute.handle];
                  setAttributes?.(newAttributes);
                } else {
                  setAttributes?.((prev) => ({
                    ...prev,
                    [attribute.handle]: [attribute.values[0].id],
                  }));
                }
              }}
            >
              {attribute.name}
            </Checkbox>
          );
        }

        if (attribute.type === "range") {
          return (
            <Range
              name={attribute.name}
              maxValue={100}
              minValue={0}
              key={attribute.id}
              setValues={(value) => {
                setIntAttributes?.((prev) => ({
                  ...prev,
                  [attribute.id]: value,
                }));
              }}
              values={[
                intAttributes?.[attribute.id]?.[0] ?? 0,
                intAttributes?.[attribute.id]?.[1] ?? 100,
              ]}
            />
          );
        }

        return (
          <CheckboxList
            key={attribute.id}
            checked={attributes?.[attribute.handle] ?? []}
            label={attribute.name}
            options={attribute.values.map((value) => ({
              value: value.id,
              label: value.value,
            }))}
            onChange={(values) =>
              setAttributes?.((prev) => ({
                ...prev,
                [attribute.handle]: values,
              }))
            }
          />
        );
      })}
    </VStack>
  );
});

Once we’ve utilized the attributes hook, the next step is to introduce the products hook for retrieving responses from the backend. Personally, I prefer SWR for this purpose, but feel free to use any fetch abstraction that suits your preference.

import { $api } from "@/shared/api";
import { Product } from "@medusajs/client-types";
import useSWR, { Fetcher } from "swr";

const fetcher: Fetcher<
  { products: Product[]; count: number },
  Record<string, unknown>
> = (params) =>
  $api
    .get(`store/products`, {
      params,
    })
    .then((res) => res.data);

export const PRODUCTS_KEY = `store/products`;

export const useProducts = (params: Record<string, unknown>) => {
  const { data, error, isLoading } = useSWR(
    [PRODUCTS_KEY, params],
    ([_, params]) => fetcher(params)
  );

  return {
    products: data?.products,
    count: data?.count,
    error,
    isLoading,
  };
};

Great, let’s now transition to our product list component.

export const ProductList = React.memo((props: ProductListProps) => {
  const { className } = props;
  const filters = useProductFilters();

  const representationParams = React.useMemo(
    () => ({
            attributes: filters.attributes ?? undefined,
            int_attributes: filters.intAttributes ?? undefined,
          }),
    [filters]
  );

  const { products } = useProducts(representationParams);

  return (
    <div className={classnames(cls.ProductList, {}, [className])}>
      {products?.map((product) => (
        <ProductTile product={product} key={product.id} />
      ))}
    </div>
  );
});

The result

custom attributes gif

Other blog posts

Why Are AI Product Descriptions Beneficial for Your Online Store's Success?

Product descriptions play a pivotal role in attracting and retaining customers. They are not just mere descriptions - they are the bridge that connects your product to potential buyers...

A Closer Look at Medusa.js: Features Overview

Medusa.js has rapidly emerged as a game-changer in the world of eCommerce solutions, capturing the attention and admiration of developers worldwide. With its promise of flexibility...

Tell us about your project

Got a project in mind? Let's make it happen! At Rigby, we turn challenges into successes. Contact us and let's take your business to new heights together.

placeholder

Grzegorz Tomaka

Co-CEO & Co-founder

LinkedIn icon
placeholder

Jakub Zbaski

Co-CEO & Co-founder

LinkedIn icon