Supercommerce API Docs
Admin API

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 groupPermission
GET /admin/menus, GET /admin/menus/:id, GET /admin/menus/:id/exportmenu: read
POST /admin/menus, POST /admin/menus/:id/duplicatemenu: create
PUT /admin/menus/:idmenu: update
DELETE /admin/menus/:idmenu: delete
GET /admin/menus/:menuId/itemsmenuItem: read
POST /admin/menus/:menuId/items, POST /admin/menus/:menuId/items/:itemId/duplicatemenuItem: create
PUT /admin/menus/:menuId/items/:itemId, PATCH /admin/menus/:menuId/items/reordermenuItem: update
DELETE /admin/menus/:menuId/items/:itemIdmenuItem: delete
POST /admin/menus/:id/importmenuItem: create and menuItem: delete

Response envelope

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409UNIQUE_VIOLATION (slug collision)
500INTERNAL_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

  • parentId is a self-reference within the same menu. null means a root entry.
  • position orders 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.
linkTypetargetIdurl
CUSTOMignoredthe literal destination; may be null for a heading that only groups children
CATEGORY / TAG / BRAND / PRODUCT / PAGErequired — the entity's idrecomputed 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

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
};
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.


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 200MenuResponse & { 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 201MenuResponse. 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 201MenuResponse & { items: MenuItemResponse[] }.


Item endpoints

GET /admin/menus/:menuId/items — List every item

Response 200MenuItemResponse[], 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 201MenuItemResponse.

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 201MenuItemResponse (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:

modeBehaviour
replace (default)Deletes every existing item in the menu, then inserts the imported tree — one transaction, so a failure leaves the old tree intact
appendKeeps 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

RuleValue
Max items per import1000
Max nesting5 levels (same cap as the write API)
versionMust 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.


  • settings — the store/navigation group binds a menu slug to each storefront slot; store/storefront_urls supplies the path patterns typed links resolve against.
  • dynamic-link — flat tile collections for content slots (promo grids, "explore" rows). See admin/dynamic-link.md.

On this page