Hierarchical Menus with Callback Data
Hello! In our last lesson, you saw how to create dynamic, single-message interfaces by editing and deleting messages in response to button clicks. We even built a simple counter where the button's callback_data held part of the UI's state (counter-plus-1).
Today, we're going to build on that idea in a big way. We'll focus entirely on designing the data structure that powers complex, multi-level menus. The learning outcome for this lesson is to structure callback data to represent a hierarchical menu system.
For a simple "Yes/No" prompt, a callback string like "confirm_delete" is sufficient. But for an application-like UI with nested menus—like the ones you're interested in building—this ad-hoc approach quickly becomes unmanageable. We need a systematic way to represent the user's location in the menu hierarchy and the actions they can take. You can think of this as designing a client-side routing system for a single-page application, but for a Telegram bot. We'll define the "URL schemas" for our bot's UI.
The Problem with Flat Callback Data
Let's consider a simple menu with a main screen and a settings screen. In previous lessons, you've seen examples where the callback data might look like this:
"show_settings""show_about""back_to_main"
This works, but it has a major drawback: for every single button, you need to write a unique handler:bot.callbackQuery("show_settings", ...)bot.callbackQuery("show_about", ...)bot.callbackQuery("back_to_main", ...)
Now, imagine implementing the menu from Telegram's own BotFather, which has multiple levels of navigation.

If the user clicks "Edit Bot", they are taken to a second-level menu:

Trying to manage this hierarchy with a flat list of unique string identifiers for every button would lead to a spaghetti of handlers. It's not scalable, and it's not maintainable. We need to embed the hierarchy into the data itself.
A Structured Convention for Callback Data
The solution is to establish a consistent format—a convention—for our callback_data strings. A powerful and common convention is to use a delimited string that acts like a route or a path. A simple choice is to use colons (:) to separate parts of an identifier.
Let's define a structure: prefix:path:payload
prefix: A top-level category telling us the general purpose of the button. Is it for navigation (menu), performing a direct action (do), or something else?path: Describes the destination or the specific item. For a menu, this could be the path to a sub-menu, likesettings:audio.payload(optional): Any extra data needed for the action, like an item ID. For example,do:delete_item:123.
Let's redesign the simple "main/settings" menu with this convention:
-
Main Menu Keyboard:
const mainMenu = new InlineKeyboard() .text("⚙️ Settings", "menu:settings") // Go to 'settings' menu .text("ℹ️ About", "menu:about"); // Go to 'about' menu -
Settings Menu Keyboard:
const settingsMenu = new InlineKeyboard() .text("🔔 Notifications", "menu:settings_notifications") // Go to a deeper menu .text("⬅️ Back", "menu:main"); // Go back to 'main' menu
The beauty of this approach is that we no longer need a separate handler for each button. We can create a single, "smarter" handler that parses these strings. We could, for example, use a regular expression to catch all navigation events.
// A single handler for ALL menu navigation clicks.
bot.callbackQuery(/^menu:(.+)/, async (ctx) => {
// The part of the string matched by (.+) is available in ctx.match
const destination = ctx.match[1]; // e.g., "settings", "main", "settings_notifications"
// Here, you would have logic to determine which menu to show
// based on the 'destination' string.
await ctx.answerCallbackQuery();
// We will build the logic to actually edit the message in the next lesson.
console.log(`User wants to navigate to: ${destination}`);
});
This single handler now acts as a router. It intercepts all clicks whose callback data starts with menu:, extracts the destination, and can then decide which new menu (which new keyboard and text) to display. This is far more scalable and maintainable.
Design Exercise: Structuring the BotFather Menu
Let's apply this concept to the BotFather UI shown in the images above. How would you design the callback_data for its buttons using our prefix:path convention?
Level 1: Main Bot Menu (LINK)
API Token: This is a direct action that reveals a secret.do:get_tokenEdit Bot: This navigates to a sub-menu.menu:editBot Settings: Navigates to another sub-menu.menu:settingsPayments: Navigates to the payments sub-menu.menu:paymentsDelete Bot: This should probably show a confirmation first.do:delete_confirm« Back to Bots List: Navigates up to the list of all bots.menu:list
Level 2: Edit Bot Menu (LINK)
This menu is shown after clicking the button with menu:edit.
Edit Name: This likely triggers a conversational flow to get the new name.do:edit_nameEdit Description:do:edit_descriptionEdit About:do:edit_aboutEdit Botpic:do:edit_botpicEdit Commands: Navigates to a command editor sub-menu.menu:edit_commands« Back to Bot: Navigates back to the previous menu (Level 1).menu:main
By designing our data this way, we've created a logical, self-documenting structure for our entire UI before writing a single line of handler logic to actually display it.
The High-Level Abstraction: The menu Plugin
Building this kind of router from scratch is an excellent way to understand the core mechanics. However, since this is such a common pattern, grammY provides an official plugin to manage it for you. Your background in front-end development has likely taught you the value of using well-vetted libraries over reinventing common patterns.
The official menu plugin formalizes the concepts we've just discussed into a powerful, declarative API for building complex interactive menus.
Inline and Custom Keyboards (built-in) - grammY
The grammY documentation on keyboards briefly introduces this higher-level plugin. It's worth knowing that this exists for when your menu logic becomes complex.
In the documentation, find the heading "Responding to Inline Keyboard Clicks". Read the blue "Menu Plugin" callout box that begins with this paragraph. This serves as a pointer to the more advanced, dedicated tool for this job.
While we will continue to build our menu manually for now to solidify the fundamentals, keep the menu plugin in mind. It's a powerful tool that handles the routing, state management, and message editing for you, allowing you to simply define the structure of your menu.
Conclusion
Today, we took a crucial step towards building complex, interactive bots by focusing on data architecture. Instead of just reacting to clicks, we are now designing a system that anticipates and structures all possible navigation paths within our bot's UI.
Key Takeaways:
- Using simple, unique strings for
callback_datadoes not scale for hierarchical menus. - A structured convention, such as
prefix:path:payload, allows you to embed hierarchy and intent directly into your callback data. - This approach enables the use of a single, powerful "router" handler (e.g., using a regular expression) to manage all navigation, rather than numerous specific handlers.
- Thinking about the data structure of your UI first is a powerful design paradigm that leads to more maintainable and scalable code.
- For complex scenarios, grammY provides an official
menuplugin that abstracts away this pattern, but understanding the underlying principles is essential.
In our next lesson, we will put this all together. We will implement the "router" handler that actually parses our structured callback_data and uses ctx.editMessageText to navigate between a main menu and a sub-menu, bringing our hierarchical UI to life.
Can't find a good explanation? Sign up and we'll make it for you
Sign up