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

Mercur Marketplace Leap to Medusa 2.0 and More

Mercur migrated to Medusa 2.0, introducing cleaner architecture, advanced seller management, and essential features like order splitting...

Medusa TaxJar Integration

The Medusa TaxJar Integration connects Medusa with TaxJar, a popular tool for automating sales tax calculations, making tax management simple and efficient...

Tell us about your project

Got a project in mind? Let's make it happen!

By clicking “Send Message” you grant us, i.e., Rigby, consent for email marketing of our services as part of the communication regarding your project. You may withdraw your consent, for example via hello@rigbyjs.com.
More information
placeholder

Grzegorz Tomaka

Co-CEO & Co-founder

LinkedIn icon
placeholder

Jakub Zbaski

Co-CEO & Co-founder

LinkedIn icon