Supercommerce API Docs
Full Module Docs

Navigation Module

Admin-managed storefront navigation menus. A menu is a slug-addressed tree of links rendered as the header bar, mobile drawer, or any other navigation slot; items may point at a literal URL or at a catalog entity that keeps resolving after a slug rename.

Admin-managed storefront navigation. A Menu is a slug-addressed tree of links; a MenuItem is one entry in that tree, nested under another item via parentId. An operator can create as many menus as they need — a header bar, a mobile drawer, footer columns, an app tab bar — and bind each one to a storefront slot from settings.

Source: api-modules/navigation (registered via NavigationModule.forRoot() in apps/api/src/app.module.ts).

The module is pluggable: removing the NavigationModule.forRoot() line disables the admin and store routes, and the storefront falls back to an empty nav. Schema lives in api-modules/db/src/schema/navigation.ts. Consumers integrate via the MENU_PORT / MENU_ITEM_PORT / STORE_MENU_PORT tokens — concrete service classes are not exported.

Role-scoped HTTP references: admin/navigation.md (CRUD, reorder, duplicate) and store/navigation.md (public tree read).


Why a tree table rather than a settings blob

The storefront navbar was hardcoded in the web app, and the desktop and mobile trees had already drifted apart — the same label pointing at different URLs. Storing the nav as one JSON setting would have kept that fragility: no per-item validation, no entity pickers, no reorder API. A real table gives each entry its own row, its own permissions, and a foreign key to the entity it points at.

Data model

ColumnNotes
iduuid
titleoperator-facing name
slugunique across menus — the storefront's lookup key
platformAPP / WEB / BOTH (default BOTH)
is_activehides the menu from the storefront without deleting it
metadatajsonb, free-form render hints

Slug is globally unique rather than unique per platform: if a WEB row and a BOTH row shared a slug, a WEB caller's lookup would be ambiguous. An operator who wants platform-specific headers creates header-web and header-app.

ColumnNotes
menu_idFK → menu.id, ON DELETE CASCADE
parent_idself FK, ON DELETE CASCADEnull for a root entry
positionorder among siblings, not globally
labelthe rendered text
link_typeCUSTOM / CATEGORY / TAG / BRAND / PRODUCT / PAGE
target_idthe linked entity's id (required unless CUSTOM)
urlliteral destination for CUSTOM; a resolved snapshot otherwise
badgesmall pill next to the label, e.g. "Trending"
imagestorage key, for image tiles in a mega menu
platformAPP / WEB / BOTH (default BOTH)
is_active, open_in_new_tabbooleans
metadatajsonb render hints — { "column": 2 } places a mega-menu section, { "badgeVariant": "highlight" } switches the badge pill from the default blue to yellow

An adjacency list, not a materialized path: the tree is small enough to load in one query and nest in memory, and a reparent is a single-column write rather than a subtree rewrite.

Rules the service enforces

  • Depth cap of 5 levels. The schema allows any depth; the service rejects deeper writes so an operator can't create a level the storefront could never render.
  • No cycles. An item cannot be its own parent, nor be moved beneath one of its own descendants.
  • Typed links need a target. Anything other than CUSTOM requires target_id.
  • Cross-menu ids are rejected on both create and reorder.

Platform and visibility filtering

Both filters apply to the tree, not to a flat list: an item whose parent is filtered out disappears with it, along with the rest of that subtree. That is the intuitive behaviour for a nav, but it means an item marked APP under a WEB-only parent can never appear anywhere.

For a typed item, the storefront read recomputes url from the target entity's current slug and the operator's store/storefront_urls path patterns (/product-category/:slug, /product/:slug, …). A category rename therefore fixes every menu that points at it. When the target has been deleted, the stored url snapshot is returned unchanged rather than dropping the entry.

Moving a menu between deployments

A menu is content, not configuration, so it isn't seeded into production. GET /admin/menus/:id/export produces a portable JSON document — hierarchy and order preserved, no ids — and POST /admin/menus/:id/import loads it into any menu on another deployment, replacing or appending. Typed links travel as target slugs and are re-resolved locally; an unmatched slug degrades that entry to a plain URL and is reported rather than failing the whole import. The admin UI exposes both as Export / Import buttons on the menu page.

Caching and revalidation

Store reads are cached in Redis behind a generation counter — every write bumps one key and orphans the previous generation wholesale. The same write pushes the menus and menu/<slug> tags through STOREFRONT_REVALIDATION_PORT so the Next.js ISR cache flushes too; a slug rename pushes the old slug's tag as well. Both are best-effort: a failed revalidation degrades to TTL-bounded staleness, never a failed write.

Storefront wiring

The storefront doesn't hardcode slugs. GET /store/settings/navigation supplies header_menu_slug (default header) and mobile_menu_slug (default mobile); the web app fetches both menus server-side in the root layout and hands them to the navbar through a context provider, so the nav renders with the first paint rather than popping in after hydration.

Permissions

menu: create|read|update|delete and menuItem: create|read|update|delete — see admin/admin-rbac.md. The admin UI (@sc/admin-navigation) gates its nav entry on menu:read and every write action on the matching resource/action.

On this page