Navigation Module — Storefront
HTTP surface for reading admin-managed navigation menus. A menu is a nested tree of links (header bar, mobile drawer, footer column) addressed by slug and filtered to the caller's platform.
HTTP surface for reading admin-managed navigation menus. A menu is a nested tree of links — the desktop header bar, the mobile drawer, a footer column, an app tab bar — addressed by slug and filtered to the caller's platform.
Source:
api-modules/navigation/src/controllers/store-menu.controller.ts.Admin CRUD for menus and their items is in
admin/navigation.md. The storefront fetches a whole tree in one call and renders it directly.
Conventions
Authentication
| Endpoint | Auth |
|---|---|
GET /store/menus | none (public) |
GET /store/menus/slug/:slug | none (public) |
Navigation is public marketing content — no session, no PII.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200
}Error envelope
statusCode | errorCode examples |
|---|---|
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Platform filtering
Every menu and every item carries platform (APP / WEB / BOTH, default BOTH). Pass ?platform=APP|WEB — or the x-platform header, which the query param overrides — to keep only rows whose platform is BOTH or the requested one.
Filtering applies 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. The same rule applies to isActive — hiding a parent hides everything nested under it.
Domain types
MenuNode
type MenuNode = {
id: string;
label: string;
url: string | null; // resolved destination; null for a heading that only groups children
linkType: "CUSTOM" | "CATEGORY" | "TAG" | "BRAND" | "PRODUCT" | "PAGE";
targetId: string | null; // the linked entity's id for a typed link
badge: string | null; // small pill next to the label, e.g. "Trending"
image: string | null; // storage key — resolve via storage CDN
platform: "APP" | "WEB" | "BOTH";
openInNewTab: boolean;
metadata: Record<string, unknown> | null; // render hints, e.g. { "column": 2, "badgeVariant": "highlight" }
children: MenuNode[]; // ordered; empty for a leaf
};For a typed link (linkType other than CUSTOM), url is recomputed on every read from the target entity's current slug and the operator's store/storefront_urls path patterns — so renaming a category doesn't break the nav. If the target has been deleted, the stored url snapshot is returned instead.
MenuResponse
type MenuResponse = {
id: string;
title: string;
slug: string; // lowercase alnum + hyphens, e.g. "header"
platform: "APP" | "WEB" | "BOTH";
isActive: boolean;
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string; // ISO
};MenuTreeResponse
MenuResponse plus items: MenuNode[] — the root entries, ordered.
Endpoints
GET /store/menus — List active menus
Returns every active menu without items, so a client can discover what exists instead of hardcoding slugs.
Query params
| Name | Notes |
|---|---|
platform | APP / WEB / BOTH, optional. Absent or BOTH returns every active menu. |
Headers
| Name | Notes |
|---|---|
x-platform | Fallback platform filter used only when the platform query param is omitted. |
Response 200 — MenuResponse[].
GET /store/menus/slug/:slug — Fetch one menu as a tree
Returns the menu plus its nested items, each level ordered by the admin's arrangement. Only active items matching the platform filter are included.
Path params
| Name | Notes |
|---|---|
slug | Menu slug (lowercase alnum + hyphens) |
Query params / headers — same platform / x-platform pair as the list endpoint.
Response 200 — MenuTreeResponse.
{
"data": {
"id": "01J9...",
"title": "Main Header",
"slug": "header",
"platform": "BOTH",
"isActive": true,
"metadata": null,
"createdAt": "2026-08-01T08:00:00.000Z",
"updatedAt": "2026-09-01T08:00:00.000Z",
"items": [
{
"id": "01J9...",
"label": "Skin",
"url": null,
"linkType": "CUSTOM",
"targetId": null,
"badge": null,
"image": null,
"platform": "BOTH",
"openInNewTab": false,
"metadata": null,
"children": [
{
"id": "01J9...",
"label": "Shop by Concern",
"url": null,
"linkType": "CUSTOM",
"targetId": null,
"badge": null,
"image": null,
"platform": "BOTH",
"openInNewTab": false,
"metadata": { "column": 0 },
"children": [
{
"id": "01J9...",
"label": "Acne",
"url": "/product-category/acne",
"linkType": "CATEGORY",
"targetId": "01J9...",
"badge": "Trending",
"image": null,
"platform": "BOTH",
"openInNewTab": false,
"metadata": null,
"children": []
}
]
}
]
}
]
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | No menu matches the slug, the menu is inactive, or it is scoped to another platform |
Which menu goes where
The storefront doesn't hardcode slugs — it reads them from the public store/navigation settings group (GET /store/settings/navigation):
| Key | Default | Slot |
|---|---|---|
header_menu_slug | header | Desktop header navigation bar |
mobile_menu_slug | mobile | Mobile navigation drawer |
Point both keys at the same slug to share one tree across desktop and mobile.
Caching
Reads are cached in Redis and served behind the storefront's own ISR cache, tagged menus and menu/<slug>. Every admin write flushes both, so an edit is live without waiting for the TTL.
Related modules
settings—store/navigation(which menu each slot renders) andstore/storefront_urls(the path patterns typed links resolve against). Seestore/settings.md.dynamic-link— flat, image-first tile collections for content slots. Use that for a promo grid; use this for hierarchical navigation. Seestore/dynamic-link.md.
Invoice Module — Storefront
HTTP surface for a customer to check, generate, stream, and download the GST tax invoice(s) for their own order — async generation with SSE readiness notification.
Notifications Module — Storefront
HTTP surface for the customer app: register/unregister FCM device tokens, the in-app notification feed (bell/inbox with read/seen state, SSE live stream, images), the per-user notification preference matrix, and the public token-gated marketing-email unsubscribe. The in-app channel mirrors push.