Navigation Module — Admin
HTTP surface for managing storefront navigation menus and the nested item tree inside each one. Menus are addressable by slug, read by the storefront, and this admin surface is the only writer.
HTTP surface for managing navigation menus (the storefront header bar, mobile drawer, footer columns) and the nested item tree inside each one. Menus are addressable by slug and read by the public storefront; this admin surface is the only writer.
Source:
api-modules/navigation/src/controllers/admin-menu.controller.ts,api-modules/navigation/src/controllers/admin-menu-item.controller.ts.An operator can create as many menus as they need — there is no fixed set. Which menu the storefront renders in each slot is a setting (
store/navigation), not a hardcoded slug.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching permission.
| Endpoint group | Permission |
|---|---|
GET /admin/menus, GET /admin/menus/:id, GET /admin/menus/:id/export | menu: read |
POST /admin/menus, POST /admin/menus/:id/duplicate | menu: create |
PUT /admin/menus/:id | menu: update |
DELETE /admin/menus/:id | menu: delete |
GET /admin/menus/:menuId/items | menuItem: read |
POST /admin/menus/:menuId/items, POST /admin/menus/:menuId/items/:itemId/duplicate | menuItem: create |
PUT /admin/menus/:menuId/items/:itemId, PATCH /admin/menus/:menuId/items/reorder | menuItem: update |
DELETE /admin/menus/:menuId/items/:itemId | menuItem: delete |
POST /admin/menus/:id/import | menuItem: create and menuItem: delete |
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | UNIQUE_VIOLATION (slug collision) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Menus and items are hard-deleted (no soft-delete). Deleting a menu cascades to every item; deleting an item cascades to its whole subtree. Both carry an isActive toggle that hides them from the storefront without deleting — and hiding a parent hides everything nested under it.
Tree rules
parentIdis a self-reference within the same menu.nullmeans a root entry.positionorders an item among its siblings (not globally).- A menu may nest at most 5 levels; deeper writes are rejected with 400.
- Cycles are rejected: an item cannot be its own parent, nor be moved beneath one of its own descendants.
Link types
linkType | targetId | url |
|---|---|---|
CUSTOM | ignored | the literal destination; may be null for a heading that only groups children |
CATEGORY / TAG / BRAND / PRODUCT / PAGE | required — the entity's id | recomputed on every storefront read from the entity's current slug |
Typed links survive a slug rename; CUSTOM links do not. Creating or updating a typed item without a targetId returns 400.
Domain types
MenuResponse
type MenuResponse = {
id: string;
title: string;
slug: string; // unique across menus
platform: "APP" | "WEB" | "BOTH"; // default BOTH
isActive: boolean; // default true
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string; // ISO
};MenuItemResponse
type MenuItemResponse = {
id: string;
menuId: string;
parentId: string | null;
position: number; // order among siblings
label: string;
linkType: "CUSTOM" | "CATEGORY" | "TAG" | "BRAND" | "PRODUCT" | "PAGE";
targetId: string | null;
url: string | null;
badge: string | null;
image: string | null;
platform: "APP" | "WEB" | "BOTH";
isActive: boolean;
openInNewTab: boolean;
metadata: Record<string, unknown> | null; // render hints, e.g. { "column": 2, "badgeVariant": "highlight" }
createdAt: string;
updatedAt: string;
};Admin reads return the flat item list sorted by position, not a tree — the editor rebuilds the hierarchy from parentId. The storefront read is the one that nests.
Menu endpoints
GET /admin/menus — List menus
Standard offset pagination (limit / offset), search (searchValue + searchField=title|slug), sort (sortBy / sortDirection), plus platform and isActive shortcuts.
Response 200 — paginated MenuResponse[] with metadata: { total, limit, offset, hasMore }.
GET /admin/menus/:id — Get a menu with its items
Response 200 — MenuResponse & { items: MenuItemResponse[] }. Every item is returned regardless of isActive or platform.
POST /admin/menus — Create a menu
{
"title": "Main Header",
"slug": "header",
"platform": "BOTH",
"isActive": true
}Response 201 — MenuResponse. 409 when the slug is taken.
PUT /admin/menus/:id — Update a menu
Partial body of the create shape.
DELETE /admin/menus/:id — Delete a menu
Response 204. Cascades to every item.
POST /admin/menus/:id/duplicate — Duplicate a menu
{ "title": "Main Header (staging)", "slug": "header-staging" }Clones the entire item tree, preserving hierarchy and order.
Response 201 — MenuResponse & { items: MenuItemResponse[] }.
Item endpoints
GET /admin/menus/:menuId/items — List every item
Response 200 — MenuItemResponse[], sorted by position.
POST /admin/menus/:menuId/items — Create an item
{
"label": "Acne",
"parentId": "01J9...",
"linkType": "CATEGORY",
"targetId": "01J9...",
"badge": "Trending",
"platform": "BOTH"
}position is optional — omitted, the item is appended after its last sibling.
Response 201 — MenuItemResponse.
PATCH /admin/menus/:menuId/items/reorder — Bulk move / reorder
One write for a whole drag-and-drop gesture: each entry sets both the item's new parent and its position among that parent's children.
{
"items": [
{ "itemId": "01J9...", "parentId": null, "position": 0 },
{ "itemId": "01J9...", "parentId": "01J9...", "position": 1 }
]
}Rejected with 400 on a duplicate itemId, an id from another menu, a cycle, or a move that would exceed the depth cap.
Response 200 — the full MenuItemResponse[] after the move.
PUT /admin/menus/:menuId/items/:itemId — Update an item
Partial body of the create shape, including parentId for a single reparent.
DELETE /admin/menus/:menuId/items/:itemId — Delete an item
Response 204. Deletes the item's whole subtree.
POST /admin/menus/:menuId/items/:itemId/duplicate — Duplicate an item
Clones the item and its subtree, appended after the original's last sibling.
Response 201 — MenuItemResponse (the cloned root).
Export / import
Moving a menu between deployments — staging to production, say — goes through a portable JSON document rather than a database seed.
GET /admin/menus/:id/export — Export the item tree
Returns the whole tree with hierarchy and sibling order preserved. The document carries no ids: entity ids differ per deployment, so a typed link travels as its target's slug.
{
"version": 1,
"exportedAt": "2026-09-03T10:00:00.000Z",
"menu": { "title": "Main Header", "slug": "header", "platform": "BOTH", "isActive": true, "metadata": null },
"items": [
{
"label": "Skin",
"linkType": "CUSTOM",
"targetSlug": null,
"url": null,
"badge": null,
"image": null,
"platform": "BOTH",
"isActive": true,
"openInNewTab": false,
"metadata": null,
"children": [
{
"label": "Acne",
"linkType": "CATEGORY",
"targetSlug": "acne",
"url": "/product-category/acne",
"badge": "Trending",
"image": null,
"platform": "BOTH",
"isActive": true,
"openInNewTab": false,
"metadata": { "column": 0 },
"children": []
}
]
}
]
}Order is positional — the array order is the sibling order, so the document reads the way the menu renders.
POST /admin/menus/:id/import — Import an item tree
Accepts an exported document verbatim, plus a mode:
mode | Behaviour |
|---|---|
replace (default) | Deletes every existing item in the menu, then inserts the imported tree — one transaction, so a failure leaves the old tree intact |
append | Keeps the existing items and adds the imported roots after the last one |
The menu block is provenance only — an import never renames or re-slugs the destination menu, so you can import a header export into a menu with a different slug.
Each typed link is re-resolved by targetSlug against this deployment's catalog. A slug that doesn't exist here does not fail the import: the entry is kept, degraded to a plain CUSTOM link carrying its exported url, and reported in warnings — losing a whole branch to one stale link would be worse than importing it slightly wrong.
Response 200
{
"data": {
"importedCount": 87,
"warnings": [
"\"Acne\" pointed at category \"acne\", which doesn't exist here — imported as a plain URL."
],
"items": [ /* the menu's full flat item list after the import */ ]
},
"message": "Success",
"statusCode": 200
}Limits
| Rule | Value |
|---|---|
| Max items per import | 1000 |
| Max nesting | 5 levels (same cap as the write API) |
version | Must be 1 when present |
Cache invalidation
Every write bumps the module's Redis read cache and pushes the menus + menu/<slug> tags to the storefront, so an edit is live immediately. A slug rename pushes the old slug's tag too.
Related modules
settings— thestore/navigationgroup binds a menu slug to each storefront slot;store/storefront_urlssupplies the path patterns typed links resolve against.dynamic-link— flat tile collections for content slots (promo grids, "explore" rows). Seeadmin/dynamic-link.md.
Meta Catalog Module — Admin
HTTP surface for syncing eligible product variants to a Meta Commerce Catalog (Facebook + Instagram Shops) via the Catalog Batch API (POST /{catalog_id}/items_batch) and the…
Notifications Module — Admin
HTTP surface for the admin notification operations: composing and sending broadcasts (one-shot email and/or push to a targeted audience) and inspecting the notification log…