# Build a navigation menu using the SDK 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](/learning-composable-commerce-developer-essentials/prepare-your-work-environment/overview.md) in the Developer Essentials learning path. ### Use the SDK First, let’s add our imports. ```typescript 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. ```typescript 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 = 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. ```typescript /** * Fetches all categories from commercetools using cursor-based pagination. * @returns {Promise} A promise that resolves to an array of all Category objects. */ async function getAllCategories(): Promise { 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 = 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. ```typescript // --- 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 = {}; const rootCategories: MegaMenuItem[] = []; const processedIds = new Set(); // 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. ```typescript /** * Main function to fetch all categories, process them into a sorted tree structure, * and return the result. * @returns {Promise} 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](/api/projects/types.md). 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: ```json { "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))` `` ```typescript const response: ClientResponse = 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](/learning-model-your-business-structure/stores-and-channels/apply-stores-and-channels.md#categories) 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 ```json { "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 ```json { "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"))` ``` ```typescript const response: ClientResponse = 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(); ``` ```typescript /** * 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`: ```typescript /** * 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 { // 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. ```typescript 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) }; } ``` ```typescript 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. ```typescript /** * 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 { // 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](/learning-model-your-business-structure/stores-and-channels/apply-stores-and-channels.md#categories) 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. ```typescript /** * 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 { // 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](/api/projects/types.md#customfieldsettype) of [CustomFieldEnumType](/urn?urn=ctp%3Aapi%3Atype%3ACustomFieldEnumType) 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. ```typescript /** * 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 { // 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 } ``` ## Related pages - [Area overview page with navigation](/learning-implement-product-discovery-and-presentation.md) - [Previous page: Understand the Categories API](/learning-implement-product-discovery-and-presentation/category-queries-best-practices/understand-the-categories-api.md) - [Next page: Performance and scalability](/learning-implement-product-discovery-and-presentation/category-queries-best-practices/performance-and-scalability.md)