Menus — backend-owned navigation
Shopify Storefront Menu shape verbatim (content-navigation card).
Items store a typed resource reference; url is computed at read time
from the live handle (src/lib/content/paths.ts) — a product/page handle
rename never breaks a menu. Items whose referenced resource is deleted or
not storefront-visible (draft page/post/product, inactive or internal
category) are skipped, subtree included — the payload only ever contains
renderable links. Nesting is 3 levels max.
GET /api/store/menus/:handle — one menu
- Purpose: render the storefront navigation (header
main-menu, footer menus — handles are merchant-chosen in admin → Content → Navigation). - Auth: anon (
x-client-id). - Request: path handle only.
- Response:
{
"menu": {
"handle": "main-menu",
"title": "Main menu",
"items": [
{
"title": "Shop",
"type": "collection", // frontpage|collection|product|category|page|blog_post|external
"url": "/collections/all", // computed at read time; "/" for frontpage; verbatim for external
"resourceId": "pcol_<hex>", // null for frontpage/external
"items": [ /* same shape, ≤ 3 levels total */ ]
}
]
}
}- Errors:
404 {code: "not_found"}— unknown OR deleted handle. A store with zero menus 404s every handle; the storefront must handle this gracefully (render no nav) — the API never 500s for it. - SDK:
menus.getMenu(client, handle) - Components: header navigation, footer link lists, mobile drawer.
- Settings: menus are authored in admin → Content → Navigation; deleting a referenced resource silently drops its item here.
ETag / caching semantics
- Every 200 carries
ETag(sha1 of the payload) andCache-Control: public, s-maxage=60, stale-while-revalidate=3600. - A request with
If-None-Match: <etag>answers 304 with an empty body when the menu is unchanged. Browsers do this transparently. Next.js RSC callers:menus.getMenu(client, handle)takes no per-callRequestOptions(none of the SDK domain functions forward them yet) — cache at the route level (export const revalidate = 60) or supply a cachingfetchinStorefrontClientConfig.fetchinstead of hand-rolling ETag replay. - The
menu.updateddomain event fires on every menu mutation — the future cache-revalidation hook.
# Unknown handle → the clean 404 contract with the stable code (the state
# every storefront must survive: a deleted menu it still references).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
"$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
curl -s "$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID" \
| grep -q '"code":"not_found"'Menus are admin-authored and the shared dev tenant seeds none, so the happy path (payload shape, ETag → 304 round-trip) is pinned executably by
tests/store/menus-store.test.tsand the SDK contract testtests/contract/sdk-content.contract.test.ts, which create a menu with admin auth.