When building a CRM like Horilla, adding a new module usually means adding a new page, model, permissions, and, of course, a way for users to reach that page.

The sidebar is one of the most important parts of that experience. It gives users a quick way to move between modules such as Leads, Contacts, Accounts, Opportunities, and Quotes.

Instead of adding every new menu item directly to the sidebar template, Horilla uses a menu registry. This makes the navigation system more flexible and keeps individual apps responsible for their own menu entries.

In this article, we’ll look at how the normal sidebar menu works in Horilla CRM and how you can register a new menu item without modifying the shared sidebar template.

How the Horilla Sidebar Works

The sidebar has two main levels:

  • Main sections — the icons displayed in the sidebar rail.
  • Sub-sections — the clickable menu items displayed under each section.

For example, a CRM might have a Sales section containing:

Sales
├── Leads
├── Opportunities
└── Quotes

The Sales entry is a main section, while Leads, Opportunities, and Quotes are sub-sections.

Horilla keeps these registrations inside:

horilla/menu/

The two important files are:

main_section_menu.py
sub_section_menu.py

The first manages the main sidebar sections, while the second manages the links displayed inside those sections.

The Menu Registry

The registry uses a simple decorator-based approach.

The main section registry looks like:

main_section_menu: list = []

def register(cls):
main_section_menu.append(cls)
return cls

The sub-section registry works in the same way:

sub_section_menu: list = []

def register(cls):
    sub_section_menu.append(cls)
    return cls

Because of this, registering a menu item is as simple as decorating a class.

You don’t have to manually add the class to a list or modify the sidebar template.

Where Should You Register a Menu?

Each application that needs to add something to the sidebar can have its own menu.py.

For example:

quotes/
├── models.py
├── signals.py
├── registration.py
├── menu.py
└── apps.py

This keeps the menu configuration close to the application that owns it.

It also means that when the Quotes module is removed or changed, you don’t have to search through a shared sidebar template to find its navigation code.

How menu.py Gets Loaded

Creating menu.py isn’t enough. Horilla also needs to import it.

This is handled by AppLauncher.

For example:

class QuotesConfig(AppLauncher):
    name = "quotes"
    auto_import_modules = ["registration", "signals", "menu"]

The important part is:

“menu”

When Horilla starts the application, AppLauncher automatically imports the modules listed in auto_import_modules.

As a result, the decorators inside menu.py are executed and the menu entries are added to the registry.

If you forget “menu”, your registration code won’t be loaded.

Creating a New Sidebar Section

Sometimes a new module needs its own top-level icon.

In that case, use main_section_menu.register.

For example:

from horilla.menu import main_section_menu
from horilla.utils.translation import gettext_lazy as _




@main_section_menu.register
class SalesSection:
    section = "sales"
    name = _("Sales")
    icon = "/assets/icons/sales.svg"
    position = 1

This creates a Sales section in the sidebar.

The important property here is:

section = “sales”

This value acts as the identifier for the section.

A main section normally needs one or more sub-sections before it becomes useful to users.

Adding a Menu Item Under an Existing Section

In most cases, you won’t need to create a new section.

Instead, you’ll add a new link under an existing section.

For example:

from django.urls import reverse_lazy
from horilla.menu import sub_section_menu, MAIN_CONTENT_HX_ATTRS
from horilla.utils.translation import gettext_lazy as _




@sub_section_menu.register
class QuotesSubSection:
    section = "sales"
    app_label = "quotes"
    verbose_name = _("Quotes")
    icon = "/assets/icons/quote.svg"
    url = reverse_lazy("quotes:quotes_view")
    position = 3
    perm = ["quotes.view_quote", "quotes.view_own_quote"]
    attrs = MAIN_CONTENT_HX_ATTRS
PropertyExampleDescription
sectionsection = “sales”Connects the menu item to the Sales section. The value must match an existing main section.
app_labelapp_label = “quotes”Identifies the application associated with the menu item.
verbose_nameverbose_name = _(“Quotes”)Defines the label displayed to users in the sidebar. Using gettext_lazy also makes the label ready for translation.
iconicon = “/assets/icons/quote.svg”Specifies the SVG icon displayed alongside the menu item.
urlurl = reverse_lazy(“quotes:quotes_view”)Defines the URL of the Quotes page. Using reverse_lazy() keeps the URL connected to Django’s URL configuration instead of hardcoding it.
positionposition = 3Controls the order in which the menu item appears within the section.
permperm = [“quotes.view_quote”, “quotes.view_own_quote”]Defines the permissions required to display the menu item. This is useful when different CRM users have different access levels.
attrsattrs = MAIN_CONTENT_HX_ATTRSProvides Horilla’s standard HTMX navigation behavior, allowing the page to load into the main content area without a full browser page reload._

Adding a Module to an Existing Section

One of the useful parts of this system is that different applications can share the same sidebar section.

For example, suppose the Accounts app owns the People section:

@main_section_menu.register
class PeopleSection:
section = "people"
name = _("People")
icon = "/assets/icons/customer.svg"
position = 1

The Contacts app can then add its menu item to the same section:

@sub_section_menu.register
class ContactsSubSection:
    section = "people"
    app_label = "contacts"
    verbose_name = _("Contacts")
    icon = "/assets/icons/contact.svg"
    url = reverse_lazy("contacts:contacts_view")
    position = 2
    attrs = MAIN_CONTENT_HX_ATTRS
    perm = ["contacts.view_contact", "contacts.view_own_contact"]

Notice that Contacts doesn’t register another PeopleSection.

It simply uses:

section = “people”

This keeps the section ownership clear while allowing multiple apps to contribute menu items.

A Real Example: Adding Quotes to Sales

Let’s say you’re developing a new Quotes module.

You already have:

Sales
├── Leads
└── Opportunities

Now you want:

Sales
├── Leads
├── Opportunities
└── Quotes

Create:

quotes/menu.py

and add:

from django.urls import reverse_lazy
from horilla.menu import sub_section_menu, MAIN_CONTENT_HX_ATTRS
from horilla.utils.translation import gettext_lazy as _




@sub_section_menu.register
class QuotesSubSection:
    section = "sales"
    app_label = "quotes"
    verbose_name = _("Quotes")
    icon = "/assets/icons/quote.svg"
    url = reverse_lazy("quotes:quotes_view")
    position = 3
    perm = ["quotes.view_quote", "quotes.view_own_quote"]
    attrs = MAIN_CONTENT_HX_ATTRS

Then make sure the app configuration loads the menu:

class QuotesConfig(AppLauncher):

class QuotesConfig(AppLauncher):
    name = "quotes"
    auto_import_modules = ["registration", "signals", "menu"]

That’s it.

There is no need to open sidebar.html and manually add a Quotes link.

Understanding Menu Ordering

The position value determines where a menu item appears, but there is one detail that can be easy to overlook.

Horilla handles positions in the following order:

  1. Non-negative values are sorted in ascending order.
  2. None values are placed after those positions.
  3. Negative values are placed at the end.

So:

position = 1
comes before:
position = 2
But:
position = -1
does not mean “first.”

Negative positions are placed at the end.

This is worth remembering when you’re trying to control the order of several menu items.

Why This Design Is Useful

The biggest advantage of the registry approach is that the sidebar doesn’t need to know about every application in Horilla.

Each application declares what it wants to add:

quotes/menu.py
contacts/menu.py
leads/menu.py

The shared menu system collects those registrations and the sidebar renders them.

This gives Horilla a cleaner architecture:

  • Apps own their navigation.
  • The sidebar template stays simple.
  • New modules can be added without changing shared templates.
  • Permissions stay close to the menu definition.
  • Multiple apps can contribute to the same section.
  • Menu registration follows the same modular approach used elsewhere in Horilla.

As the CRM grows, this becomes increasingly useful. Instead of maintaining one large sidebar file with conditions for every application, each module can manage its own navigation independently.

Registering a menu in Horilla CRM is a small task, but the registry design behind it makes a big difference to the overall architecture.

If you need a completely new sidebar section, use:

@main_section_menu.register

If you simply want to add a page under an existing section, use:

@sub_section_menu.register

Then place the registration in your app’s menu.py and make sure “menu” is included in auto_import_modules.

The main idea is simple: let each app register its own navigation instead of hardcoding the entire sidebar in one place.

That keeps Horilla’s sidebar modular, easier to extend, and much easier to maintain as new CRM modules are added.

Share this article