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.
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 theirid
. It's essential for thewhere
parameter to correctly fetch the next sequential batch in cursor pagination. This is preferred overoffset
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 whoseid
is greater than thelastId
from the previous batch. Combined withsort
, this ensures we get the next distinct set of results without skipping or repeating. The initiallastId
(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.
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
For example, you can create a boolean field to indicate whether a Category should appear in a frontend menu.
Create the Custom Type
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
isMenuDisplayed
Custom Field to true
for those intended for the menu. This enables you to query only the necessary Categories for your frontend.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
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
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
});
}
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;
}
orderHint
value, their order becomes indeterministic which might result in some of them being skipped during pagination.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.id
, ensures the returned Categories have a deterministic order and no Categories are skipped during pagination.orderedCategory
array returned from the getCategories()
method we created to build a tree representation for the menu.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.
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.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
}
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
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.
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
brand
with the Custom Field Type Set of CustomFieldEnumType for their Categories.brand
Custom Field indicates whether a Category belongs to the Electronic High Tech brand, Zenith Living brand, or both brands.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
}