Build a navigation menu using the SDK

Elevate, May 20-22-2025, Miami Beach, Florida

Learn how to build navigation menus using the Composable Commerce SDK.

After completing this page, you should be able to:

  • Use the SDK to query Categories and build a Category tree based on criteria like orderHint and Custom Fields.

Let’s anchor our journey in a real-world example—one that highlights the challenges and opportunities of working with Composable Commerce at an enterprise scale.

Enter Zen Electron

Zen Electron is a major player in the electronics and home appliance market in Australia and New Zealand. With a strong presence and a reputation for innovation, this company offers a perfect case study to explore how Composable Commerce solves complex business challenges.

Zen Electron operates two distinct brands under its umbrella:

  • Electronics High Tech, a fast-paced, budget-friendly electronics retailer known as The Discount King.
  • Zenith Living, a high-end home appliance brand focused on service and experience, living up to its slogan Service with a Smile.

These brands serve slightly different audiences and have slightly different Categories and menus.

Requirement 1: build a mega menu

Let’s look at how we can develop a mega menu for Zen Electron’s two brands. To prioritize speed and getting a working demo up and running, you have been tasked with setting up the mega menu by using the Categories API from Composable Commerce. This will remove the need for integrating with a CMS at this stage.

Let’s look at how we use the Composable Commerce SDK to achieve this. For more details on how to set up and use the SDK in your own local environment, see the Prepare your work environment module in the Developer Essentials learning path.

Use the SDK

First, let’s add our imports.

import { apiRoot } from '../ctp-root';

import {
  Category,
  CategoryPagedQueryResponse,
  LocalizedString,
} from '@commercetools/platform-sdk';
import { ClientResponse } from '@commercetools/ts-client';

Now build the query for fetching Categories.

const limit = 40;
let lastId = '00000000-0000-0000-0000-000000000000'; // Start with the first possible UUID
const allCategories: Category[] = [];
let hasMore = true;

const response: ClientResponse<CategoryPagedQueryResponse> = await apiRoot
  .categories()
  .get({
    queryArgs: {
      limit: limit,
      sort: 'id asc',
      withTotal: false,
      where: `id > "${lastId}"`,
      // Add other filters here if necessary
    },
  })
  .execute();

Key takeaways

  • limit: 40: sets the maximum number of Categories per API call. Adjusting this affects the number of requests vs. the size of each response.
  • sort: 'id asc': this sorts Categories by their id. It's essential for the where parameter to correctly fetch the next sequential batch in cursor pagination. This is preferred over offset where possible.
  • withTotal: false: this improves performance by telling the API not to calculate the total number of Categories, which isn't needed since we're iterating until no more results are returned.
  • where: id > "${lastId}": this implements the cursor. It filters for Categories whose id is greater than the lastId from the previous batch. Combined with sort, this ensures we get the next distinct set of results without skipping or repeating. The initial lastId (all zeros) effectively starts the query from the beginning. This will not filter out any Categories, which is not ideal. We will fix this in later steps.
Next create a getAllCategories function that implements the complete process for fetching all Categories. It uses a while loop and async/await to continuously call the API, employing cursor-based pagination by passing the lastId from the previous response to retrieve the next batch until no more Categories are found.
/**
 * Fetches all categories from commercetools using cursor-based pagination.
 * @returns {Promise<Category[]>} A promise that resolves to an array of all Category objects.
 */
async function getAllCategories(): Promise<Category[]> {
  const limit = 40;
  let lastId: string | undefined = undefined; // Start without a specific lastId for the first query
  const allCategories: Category[] = [];
  let hasMore = true;
  // Removed: let page = 1;

  console.log('Starting category fetch...');

  while (hasMore) {
    try {
      // Removed page number from log
      console.log(
        `Fetching next batch (limit: ${limit}, starting after ID: ${
          lastId ?? 'beginning'
        })`
      );
      // Type the response for clarity
      const response: ClientResponse<CategoryPagedQueryResponse> = await apiRoot
        .categories()
        .get({
          queryArgs: {
            limit: limit,
            sort: 'id asc', // Keep sorting by ID for pagination consistency
            withTotal: false,
            // Use where clause for cursor pagination: id > lastId
            // Only add the 'where' clause if lastId is defined
            ...(lastId && { where: `id > "${lastId}"` }),
            // Add other filters here if necessary
            // Example: where: `id > "${lastId}" and published = true`
          },
        })
        .execute();

      const results: Category[] = response.body.results;

      if (results.length > 0) {
        // Use push with spread operator for potentially better performance/readability
        allCategories.push(...results);

        // Update lastId with the ID of the *last* category fetched in this batch
        lastId = results[results.length - 1].id;
        hasMore = results.length === limit; // Continue if we received a full page
        // Removed page number and page increment from log
        console.log(
          `Fetched ${results.length} categories this batch. Total so far: ${allCategories.length}. Next batch starts after ID: ${lastId}`
        );
        // Removed: page++;
      } else {
        hasMore = false; // No more results found
        console.log('No more categories found in the last query.');
      }
    } catch (error: unknown) {
      // Type error as unknown
      // Basic error logging, consider more robust error handling/reporting
      // Removed page number from error log
      console.error(
        `Error fetching categories batch:`,
        error instanceof Error ? error.message : error
      );
      hasMore = false; // Stop fetching on error
      // Optionally re-throw or handle the error differently
      // throw new Error(`Failed to fetch categories: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

  console.log(
    `Finished fetching. Total categories retrieved: ${allCategories.length}.`
  );
  return allCategories;
}

Now we have all the code needed to get the data from Composable Commerce in an efficient way.

The next step is processing Category data into a smaller and more useful structure. The ideal structure and solution for this depends on your application specifics.

// --- Define the structure for the optimized menu item ---
// Added orderHint to facilitate sorting
interface MegaMenuItem {
  id: string;
  name: LocalizedString; // Keep the full localized object
  slug: LocalizedString; // Keep the full localized object
  orderHint: string; // Store the original orderHint (it's always a string in CT)
  children: MegaMenuItem[];
}

/**
 * Comparison function for MegaMenuItems based on orderHint.
 * Tries to compare numerically, falls back for non-numeric strings.
 * Puts items with non-numeric or missing orderHints at the end.
 * @param {MegaMenuItem} a - First item to compare.
 * @param {MegaMenuItem} b - Second item to compare.
 * @returns {number} Negative if a < b, positive if a > b, 0 if equal.
 */
function compareMenuItemsByOrderHint(a: MegaMenuItem, b: MegaMenuItem): number {
  const fallbackValue = Infinity; // Use Infinity to push non-numeric/missing hints to the end

  const parseHint = (hint: string | undefined | null): number => {
    // Treat null, undefined, or empty string as needing the fallback
    if (hint == null || hint === '') return fallbackValue;
    const num = parseFloat(hint);
    // If parseFloat results in NaN, use the fallback value
    return isNaN(num) ? fallbackValue : num;
  };

  const aValue = parseHint(a.orderHint);
  const bValue = parseHint(b.orderHint);

  // Primary sort: by parsed orderHint value
  if (aValue !== bValue) {
    return aValue - bValue;
  }

  // Optional Secondary sort (for stability when orderHints are equal or non-numeric):
  // You could sort by name or ID here if needed. Let's use ID for determinism.
  if (a.id < b.id) return -1;
  if (a.id > b.id) return 1;
  return 0; // Should only happen if IDs are identical (unlikely)
}

/**
 * Builds a hierarchical tree structure from a flat list of categories,
 * sorting siblings by orderHint.
 * @param {Category[]} categories - A flat array of Category objects.
 * @returns {MegaMenuItem[]} An array of root-level MegaMenuItem objects, each potentially containing children, sorted by orderHint.
 */
function buildCategoryTree(categories: Category[]): MegaMenuItem[] {
  // Use Record for a typed key-value map
  const categoryMap: Record<string, MegaMenuItem> = {};
  const rootCategories: MegaMenuItem[] = [];
  const processedIds = new Set<string>(); // Keep track of IDs already added to the tree structure

  console.log('Building category tree...');

  // 1. Create a map of ID -> optimized category object (MegaMenuItem) including orderHint
  categories.forEach((category: Category) => {
    categoryMap[category.id] = {
      id: category.id,
      name: category.name,
      slug: category.slug,
      orderHint: category.orderHint, // Store the orderHint
      children: [],
    };
  });

  // 2. Build the tree structure by assigning children to their parents
  categories.forEach((category: Category) => {
    if (processedIds.has(category.id)) {
      return; // Skip already processed
    }

    const optimizedCategory = categoryMap[category.id];
    if (!optimizedCategory) {
      console.error(
        `Consistency error: Category ${category.id} not found in map during tree build.`
      );
      return;
    }

    if (!category.parent) {
      rootCategories.push(optimizedCategory);
      processedIds.add(category.id);
    } else {
      const parentId = category.parent.id;
      const parentNode = categoryMap[parentId];

      if (parentNode) {
        parentNode.children.push(optimizedCategory);
        processedIds.add(category.id);
      } else {
        console.warn(
          `Parent category with ID ${parentId} not found in the fetched list for child category ${
            category.id
          } (${category.name?.['en'] ?? 'N/A'}). Adding child as a root node.`
        );
        rootCategories.push(optimizedCategory);
        processedIds.add(category.id);
      }
    }
  });

  // 3. Sort children within each node *after* the tree is built
  console.log('Sorting children within each category node by orderHint...');
  Object.values(categoryMap).forEach((node) => {
    if (node.children.length > 1) {
      // Sort the children array in place
      node.children.sort(compareMenuItemsByOrderHint);
    }
  });

  // 4. Sort the root categories *after* the tree is built
  console.log('Sorting root categories by orderHint...');
  if (rootCategories.length > 1) {
    // Sort the root array in place
    rootCategories.sort(compareMenuItemsByOrderHint);
  }

  console.log(
    `Category tree built and sorted. Found ${
      rootCategories.length
    } root categories. Total nodes processed: ${processedIds.size}.`
  );

  const uniqueInputIds = new Set(categories.map((c) => c.id)).size;
  if (processedIds.size !== uniqueInputIds) {
    console.warn(
      `Mismatch between processed nodes (${processedIds.size}) and unique input IDs (${uniqueInputIds}). This might indicate issues with duplicate handling or orphaned categories.`
    );
  }

  return rootCategories;
}

Now we can execute our code and console log the results. Try checking how many Categories exist in your Project and the total number of Categories fetched by the code, the count should match.

/**
 * Main function to fetch all categories, process them into a sorted tree structure,
 * and return the result.
 * @returns {Promise<MegaMenuItem[]>} A promise resolving to the sorted category tree structure.
 */
export async function getAndProcessMegaMenuCategories(): Promise<
  MegaMenuItem[]
> {
  try {
    const allCategories: Category[] = await getAllCategories();

    if (allCategories.length === 0) {
      console.log('No categories found or fetched.');
      return [];
    }

    const categoryTree: MegaMenuItem[] = buildCategoryTree(allCategories);

    console.log(
      'Successfully processed categories into sorted mega menu structure.'
    );
    return categoryTree;
  } catch (error) {
    console.error(
      'An error occurred during the category processing pipeline:',
      error
    );
    return [];
    // Or: throw error;
  }
}

// --- Example Usage ---
async function run() {
  console.log('Starting category processing for mega menu...');
  const megaMenuData = await getAndProcessMegaMenuCategories();

  if (megaMenuData.length > 0) {
    console.log(
      `Successfully generated sorted mega menu data with ${megaMenuData.length} root items.`
    );
    // Log the *entire* generated structure
    console.log('--- Start Full Generated Structure ---');
    console.log(JSON.stringify(megaMenuData, null, 2)); // Stringify the whole array with pretty printing
    console.log('--- End Full Generated Structure ---');
  } else {
    console.log(
      'Mega menu data generation resulted in an empty or failed structure.'
    );
  }
}

run().catch((err) => {
  console.error('Unhandled error in run:', err);
});

Filter Categories for specific display needs

Querying all Categories isn't always efficient, especially when you only need a subset for frontend display (like a navigation menu). To achieve this, you can leverage Custom Types. By adding Custom Fields to your Categories, you can flag them for specific purposes.

For example, you can create a boolean field to indicate whether a Category should appear in a frontend menu.

Create the Custom Type

Here's the request body to create a Type for Categories that includes an isMenuDisplayed boolean field:
{
  "key": "category-menu-display-type",
  "name": {
    "en": "Category Menu Display Flag"
  },
  "description": {
    "en": "Defines a boolean flag for Categories to control their visibility in frontend menus."
  },
  "resourceTypeIds": ["category"],
  "fieldDefinitions": [
    {
      "name": "isMenuDisplayed",
      "label": {
        "en": "Show in Menu"
      },
      "required": false,
      "type": {
        "name": "Boolean"
      },
      "inputHint": "SingleLine"
    }
  ]
}

Use the Custom Field

Once this Type is created, you can update your Categories (via the API or Merchant Center) by setting the isMenuDisplayed Custom Field to true for those intended for the menu. This enables you to query only the necessary Categories for your frontend.
Here we update the where filter for Categories where the isMenuDisplayed value is true. We combine the id cursor with this new filter: where: `id > "${lastId} " and custom(fields(isMenuDisplayed=true))`
const response: ClientResponse<CategoryPagedQueryResponse> = await apiRoot
  .categories()
  .get({
    queryArgs: {
      limit: limit,
      sort: 'id asc',
      withTotal: false,
      where: `id > "${lastId} " and custom(fields(isMenuDisplayed=true))`,
      // Add other filters here if necessary
    },
  })
  .execute();

Category by brand

Now we need to get the relevant Category tree based on each brand (for both Electronics High Tech and Zenith Living). Here is a refresher on what data and configuration the brands share and what is unique.
To do this we use another Custom Type, so that the Category can specify which brand it belongs to. We will need to use an update action to modify the existing Type category-menu-display-type or we can create a new Type that supports this new field and the existing isMenuDisplayed field.

Body for creating a new Type

{
  "key": "category-boolean-enum-type",
  "name": {
    "en": "Boolean and Enum Custom Fields for Categories"
  },
  "description": {
    "en": "Defines a boolean flag for Categories and an enum for the website"
  },
  "resourceTypeIds": ["category"],
  "fieldDefinitions": [
    {
      "name": "isMenuDisplayed",
      "label": {
        "en": "Show in Menu"
      },
      "required": false,
      "type": {
        "name": "Boolean"
      }
    },
    {
      "name": "website",
      "label": {
        "en": "The website that the Category belongs to"
      },
      "required": false,
      "type": {
        "name": "Enum",
        "values": [
          {
            "key": "electronics-high-tech",
            "label": "Electronics High Tech"
          },
          {
            "key": "zenith-living",
            "label": "Zenith Living"
          }
        ]
      }
    }
  ]
}

Body for updating the existing Type

{
  "version": 1,
  "actions": [
    {
      "action": "addFieldDefinition",
      "fieldDefinition": {
        "name": "website",
        "label": {
          "en": "The website that they Category belongs to"
        },
        "required": false,
        "type": {
          "name": "Enum",
          "values": [
            {
              "key": "electronics-high-tech",
              "label": "Electronics High Tech"
            },
            {
              "key": "zenith-living",
              "label": "Zenith Living"
            }
          ]
        }
      }
    }
  ]
}

One of the benefits of updating the existing Type over creating a new one, is that it preserves existing values in Custom Fields. Creating a new Type requires that you migrate existing values to the new Type.

Querying the new custom fields

Now lets bring this all together and update the query to use these new custom fields: custom(fields(isMenuDisplayed=true and website="electronics-high-tech"))
where: `id > "${lastId} " and custom(fields(isMenuDisplayed=true and website="electronics-high-tech"))`
const response: ClientResponse<CategoryPagedQueryResponse> = await apiRoot
  .categories()
  .get({
    queryArgs: {
      limit: limit,
      sort: 'id asc',
      withTotal: false,
      where: `id > "${lastId} " and custom(fields(isMenuDisplayed=true and website="electronics-high-tech"))`,
    },
  })
  .execute();
/**
* Make a REST API call to get Categories from Composable Commerce
* @param methodArgs
*/
protected async getCommercetoolsCategoriesResponse(methodArgs: object) {
   return await apiRoot
     .categories() // Use the Category REST endpoint
     .get(methodArgs) // GET request passing the method arguments
     .execute() // Execute the request
     .catch((error) => {
// TODO Implement error handling strategy. For example, handle or rethrow the error
     });
 }

/**
* Make a GraphQL API call to get Categories Composable Commerce
* @param query  GraphQL query
*/
protected async getCommercetoolsCategoryPagedQueryResponseUsingGraphQL(query: string) {
   return await apiRoot
   .graphql() // Use the GraphQL API
   .post({body: {query}}) // GraphQL uses a POST request. Pass the query in the body
   .execute()// Execute the request
   .catch((error: any) => {
	  // TODO Implement error handling strategy. For example, handle or rethrow the error
   });
}
Next, we are going to use this method to build an array of Category objects sorted by their orderHint:
/**
 * Retrieve an array of all Categories, sorted by their `orderHint` value
 * @param limit The maximum number of Categories to retrieve per page (default 20, max 500).
 * @returns A promise that resolves to an array of fetched categories sorted by their `orderHint` value.
 */
async function getCategories(limit: number = 20): Promise<Category[]> {
  // Check if the limit exceeds the maximum allowed
  if (limit > 500) {
    throw new Error('Limit cannot exceed 500');
  }
  // This array will store ordered Categories from all fetched pages
  let orderedCategories: Category[] = [];

  // Set up initial query arguments with limit, offset, and sort parameters
  const methodArgs = {
    queryArgs: {
      limit: limit, // Max Category results returned per request
      offset: 0, // Starting offset for pagination
      sort: ['orderHint', 'id'], // Sort primarily by orderHint; id is used as a tiebreaker to ensure no Categories are skipped
    },
  };
  // Fetch the first page of Categories
  let categoryResponse =
    await getCommercetoolsCategoryPagedQueryResponse(methodArgs);

  if (categoryResponse) {
    // Destructure the results, count, and total from the response body
    let { results = [], count = 0, total = 0 } = categoryResponse.body;
    // Add the result Categories to our orderedCategories array
    orderedCategories.push(...results);

    // Determine if there are more result pages
    let hasMorePages = methodArgs.queryArgs.offset + count < total;

    // If more result pages exist, continue fetching until all pages are retrieved
    while (hasMorePages) {
      // Increment the offset by the count of items we just retrieved
      methodArgs.queryArgs.offset += count;

      // Fetch the next page of Categories
      categoryResponse =
        await getCommercetoolsCategoryPagedQueryResponse(methodArgs);

      if (categoryResponse) {
        // Destructure the response of the new page
        let { results = [], count = 0, total = 0 } = categoryResponse.body;
        // Append these categories to our ordered categories array
        orderedCategories.push(...results);
        // Check if we still have more pages to get
        hasMorePages = methodArgs.queryArgs.offset + count < total;
      } else {
        // If no response is returned, stop trying to fetch more pages
        hasMorePages = false;
      }
    }
  }
  // Return the full array of fetched categories after pagination is complete
  return orderedCategories;
}
Use a tiebreaker for sorting
If multiple Categories have the same orderHint value, their order becomes indeterministic which might result in some of them being skipped during pagination.
Sorting by orderHint then id means the results are sorted by the orderHint first and when one or more Categories have the same orderHint value, they will be sorted by id. This secondary sort is referred to as the tiebreaker.
Using a tiebreaker field with a unique value, like the id, ensures the returned Categories have a deterministic order and no Categories are skipped during pagination.
Next, we will build a method that accepts the orderedCategory array returned from the getCategories() method we created to build a tree representation for the menu.
Since we only need a subset of the Category fields for the menu tree, we will also build a categoryToMenuObject() function that accepts a Category object and returns an object representing the Category in the menu.
function categoryToMenuObject(category: Category) {
  return {
    id: category.id, // Category ID
    parent: category.parent, // Parent Category ID
    orderHint: category.orderHint, // Category orderHint
    name: category.name, // Category name (per locale)
    slug: category.slug, // Category slug (per locale)
    submenus: [], // List of children (from Child Categories)
  };
}
function buildCategoryTree(categories: Category[]) {
  const categoryTree = [];
  const categoryMap: { [key: string]: any } = {};

  // Loop the categories in the map
  let menuObject;
  for (const category of categories) {
    menuObject = categoryToMenuObject(category);
    if (!category.parent) {
      // If this is a top-level category, add it to the category tree and the category map
      categoryTree.push(menuObject);
      categoryMap[category.id] = menuObject;
    } else {
      categoryMap[category.id] = menuObject;
    }
  }

  for (const category of Object.values(categoryMap)) {
    if (category.parent?.id) {
      categoryMap[category.parent.id].submenus.push(category);
    }
  }
  return categoryTree;
}

Nice work! We’ve now got our Category tree to populate the mega menu.

Let’s now look at a slightly more advanced use case.

Requirement 2: control which Categories to display

Zen Electron’s product line is expanding, and therefore so are its Categories. Zen Electron wants to control the user experience by only displaying complete Categories and hiding Categories they are working on.

The Zen Electron team decided to add the Custom Field isLive on Categories. Setting isLive = true indicates that the Category should be displayed in the mega menu. Any other isLive value will hide the Category, and its subcategories, from the mega menu.
To implement this new requirement, you will need to update the getCategories() method to only query the Categories whose custom isLive field is true.
/**
* Retrieve an array of all categories, sorted by their `orderHint` value
* @param limit The maximum number of categories to retrieve per page (default 20, max 500).
* @returns A promise that resolves to an array of fetched categories sorted by their `orderHint` value.
*/
async function getCategories(limit:number = 20): Promise<Category[]> {

// Unchanged code

 // Set up initial query arguments with limit, offset, and sort parameters
   const methodArgs = {
       queryArgs: {
           limit: limit, // Max Category results returned per request
           offset: 0, // Starting offset for pagination
	    where: 'custom(fields(isLive = “true”))' // filter out Categories where the isLive custom property is not set to ‘true’
           sort: ['orderHint', 'id'], // Sort primarily by orderHint; id is used as a tiebreaker to ensure no Categories are skipped
       }
   }

// Rest of method code
}
The query now filters out the Categories where the Custom Field isLive is not true. The buildCategoryTree() code will continue to build the Category tree for the menu, skipping all Categories where isLive is not true, along with their child Categories.

Requirement 3: load the right Category tree for each brand

Zen Electron needs to use a different Category tree for each of its brands, Electronic High Tech and Zenith Living. In this section, we explore the two approaches Zen Electron can take to manage and build the Category trees for each of their brands. We have looked at these concepts in our Stores and Channels module.

Use a top-level Category for each brand

Zen Electron can create two root-level Categories; one for each brand. The root-level Category for each brand uses the name of each respective brand, and then the Category tree for the brand appears underneath it.

To adjust the code to load the Category tree under a specified top-level Category, without adding the top-level Category itself to the tree, you need to adjust the where condition of the query to check if the ancestors array contains the top-level Category for the brand, using its id, and exclude the top-level Category id from the query.
/**
* Retrieve an array of all Categories, sorted by their `orderHint` value
* @param limit The maximum number of Categories to retrieve per page (default 20, max 500).
* @returns A promise that resolves to an array of fetched Categories sorted by their `orderHint` value.
*/
async function getCategories(topLevelId:string, limit:number = 20): Promise<Category[]> {

// Unchanged code

 // Set up initial query arguments with limit, offset, and sort parameters
   const methodArgs = {
       queryArgs: {
           limit: limit, // Max Category results returned per request
           offset: 0, // Starting offset for pagination
	    where: `custom(fields(isLive = “true”)) and ancestors(id="${topLevelId}") and id != "${topLevelId}"` // filter out top-level brand Category, Categories where the isLive custom property is not set to ‘true’, include only Categories below the brand top-level category
           sort: ['orderHint', 'id'], // Sort primarily by orderHint; id is used as a tiebreaker to ensure no categories are skipped
       }
   }

// Rest of method code
}

Use Custom Fields to assign Category to brands

Zen Electron can create a Custom Field called brand with the Custom Field Type Set of CustomFieldEnumType for their Categories.
The Custom Field enum values are “Electronic High Tech” and “Zenith Living”. The brand Custom Field indicates whether a Category belongs to the Electronic High Tech brand, Zenith Living brand, or both brands.
We will now adjust the where condition of the query to load only the Categories belonging to a certain brand.
/**
* Retrieve an array of all categories, sorted by their `orderHint` value
* @param limit The maximum number of categories to retrieve per page (default 20, max 500).
* @returns A promise that resolves to an array of fetched categories sorted by their `orderHint` value.
*/
async function getCategories(brand:string, limit:number = 20): Promise<Category[]> {

// Unchanged code

 // Set up initial query arguments with limit, offset, and sort parameters
   const methodArgs = {
       queryArgs: {
           limit: limit, // Max Category results returned per request
           offset: 0, // Starting offset for pagination
	    where: `custom(fields(isLive = “true”)) and custom(fields(brand = "${brand}"))` // filter out Categories where the isLive custom property is not set to ‘true’, include only Categories for the specified brand
           sort: ['orderHint', 'id'], // Sort primarily by orderHint; id is used as a tiebreaker to ensure no categories are skipped
       }
   }

// Rest of method code
}

Test your knowledge