Storefront menus & categories
How categories reach a headless storefront, how each storefront gets its own header menu, per-storefront product counts and auto-hiding, and the endpoints a frontend uses to render all of it.
Categories are org-global, but what a shopper sees on a given storefront is not. This page explains the three layers between your category tree and a storefront’s header menu — and the endpoints your frontend reads so it never hardcodes a menu again.
The short version
- Categories are one tree for the whole organization. See Categories & brands.
- A category appears on a storefront only when it contains products that are listed on that channel. Nothing binds a category to a channel directly — listings do.
- Each storefront channel can optionally have its own menu: a per-channel navigation tree of category pages, plain links, and headings. Without one, the storefront serves the global tree.
- Your frontend renders the header from
GET /storefront/navigation— one typed tree, already pruned to what has products on that storefront, with product counts.
Need the same product under different categories on two stores (a solar panel that’s “RV Solar” on one and “Overlanding” on the other)? That’s a custom menu, not a data-model change — see Same product, different category on each store.
How a category gets onto a storefront
A product belongs to one category. A product is sold on a storefront when at least one of its variants is listed on that channel — the Listed toggle on the channel’s SKUs tab, a bulk list, or the channel’s auto-list setting.
A category is populated on a storefront exactly when it (or, when rolled up, one of its subcategories) contains a listed product. That is the whole mechanism:
- Two storefronts on one organization share the same category tree but different listings, so the same category can have 40 products on one store and be empty on the other.
- Empty categories are hidden from the menu automatically, per storefront. Nothing to configure.
- There is no “list this category on channel X” switch. To put a category on a store, list its products there.
The channel’s Navigation tab shows this at a glance: how many categories have products on this storefront, and — once you customize the menu — which populated categories aren’t reachable from it.
Default mode: the global tree
A storefront channel with no custom menu serves your org-wide category tree, pruned per storefront:
- Categories with no listed products on that storefront are dropped.
- Ancestors of a populated category are kept so breadcrumbs resolve.
- Order follows each category’s manual
position, then name. - Category page URLs use the global category
slug.
Most single-storefront setups never leave this mode.
Custom menus: one tree per storefront
When two storefronts need different structure — a different top level, categories merged into one page, promotional links, section headings — customize the channel’s menu. On the channel, open Navigation → Customize. Foundry copies the global tree in as a starting point; from there you rearrange freely. Revert to global categories puts it back.
Nothing you do in a custom menu touches your global categories or product assignments. It is an overlay.
Menu item types
| Type | What it is | What the storefront gets |
|---|---|---|
| Category | A page with products, backed by one or more global categories | slug, product count, a category page and its product listing |
| Link | A plain link — blog, landing page, sale, external URL | href, plus openInNewTab |
| Group | A non-clickable heading that holds other items | Just a name and children |
Real headers mix these: category pages under a “Shop” group, then “Brands”, “Blog”, and “Sale” as links.
Binding items to categories
A Category item pulls products from one or more global categories. Selecting several merges them into a single storefront page — useful when your PIM taxonomy is finer than your menu should be, and the mechanism that lets two storefronts file the same product under different pages (next section).
Include subcategories (on by default) also pulls in everything nested under the selected categories. Turn it off when you want a page for exactly one node.
Same product, different category on each store
This is the case people most often assume needs a per-channel category field. It doesn’t.
A product has one global category, always. What varies per storefront is which menu item pulls that category in — and each store’s custom menu is independent, so a category mapped on one store leaks nothing into the other.
Say an organization runs RVGearPro and OffRoad Gear Pro on one catalog. A Go Power solar panel has the global category RV Solar. Both stores customize their menu:
| Store | Menu item | Pulls from |
|---|---|---|
| RVGearPro | RV Solar | RV Solar |
| OffRoad Gear Pro | Overlanding Solar Panels & Kits | Overlanding Solar and RV Solar |
The same panel shows under “RV Solar” on the first store and under “Overlanding Solar Panels & Kits” on the second. Nothing about the product changed. RVGearPro’s menu never maps Overlanding Solar, so overlanding-only products never appear there — even though the product’s global category is visible in the admin.
Everything downstream follows the menu item, not the global category: productCount, ?category= filtering, breadcrumbs, and the product’s category field in Storefront API responses all resolve to the item that owns it on that store. A category no item on the store maps is simply not exposed there.
When only some of a category should cross over. If a store wants a subset of an RV category — say the panels but not the charge controllers — make the global tree one level finer: RV Solar → Solar Panels and RV Solar → Charge Controllers. Then each store’s item pulls the leaves it wants. The global tree is your fine-grained source taxonomy; each storefront’s menu composes it. That’s the model, and the trap to avoid is building the global tree as one storefront’s finished menu — once the leaves match what any store wants to show, per-store placement is just mapping.
What you don’t need: duplicate product records, a per-channel categoryId on the listing, or leaving cross-sold products uncategorized on the second store.
Each item shows a product count for this storefront and an Empty badge when nothing is listed — an empty item stays in your editor but is hidden from the live menu automatically. The category picker shows the same per-storefront counts, so you can see what’s populated before you ship a menu.
Names, slugs, and SEO are per item
When you customize, each item’s name, slug, and SEO fields are copied from its category and then owned by the menu item:
- Renaming an item never changes its slug. A typo fix doesn’t move a live URL.
- Editing the slug moves the page URL on that storefront only. Redirect the old one on your frontend.
- Item SEO title and description override the category’s on that storefront (and take precedence over per-channel SEO overrides).
This is deliberate: a menu label (“Gizmos”) is allowed to differ from the PIM name (“Consumer Electronics — Small”), and one store’s rename shouldn’t ripple into another’s URLs.
Hidden items
Hiding an item removes it from the menu but keeps its page resolvable — handy for a page you link to from a banner but don’t want in the header.
Breadcrumbs
On a custom menu, a product’s category in Storefront API responses is the menu item that “owns” it: the first item, in menu order, whose categories include the product’s. Use it to build breadcrumbs that match the menu the shopper navigated.
What your frontend calls
All routes are under https://api.foundryims.com/api/v1/ and require a storefront key (fims_sf_*), which is pinned to one channel when created. The key decides which storefront’s view you get — a second store uses its own key.
| Need | Endpoint | Returns |
|---|---|---|
| Header menu | GET /storefront/navigation | Typed tree of CATEGORY / LINK / GROUP nodes: id, name, slug, href, openInNewTab, position, productCount, children. Pruned to what has products on this storefront. Same shape in default and custom mode — build the header from this and it keeps working when the menu is customized later. |
| Category tree (categories only) | GET /storefront/categories | Walkable category tree, same pruning. On a custom menu, links and groups are collapsed out and ids/slugs are the menu items’. Add ?expand=true to include each node’s descriptions, resolved SEO fields, and visible custom fields — the whole catalog’s category pages in one request, ideal for static builds. |
| Category page | GET /storefront/categories/:idOrSlug | Name, both rich descriptions, resolved SEO title and description, parentId, visible custom fields. |
| Products in a category | GET /storefront/categories/:idOrSlug/products | Paginated listed products, subcategories included — the result always matches the node’s productCount. On a custom menu, everything the item pulls from. Same params as /storefront/products; ?category= there accepts the same ids/slugs (several, comma-separated). |
| Everything else | /storefront/products, /storefront/brands, /storefront/search, /storefront/inventory/check | See the Storefront API reference. |
productCount on a CATEGORY node is the number of listed products under it on this storefront, including subcategories when the item rolls them up. LINK and GROUP nodes return null. Filtering products by that node (?category= or /categories/:id/products) returns exactly that set — count and filter can never disagree.
Rendering a header
const res = await fetch("https://api.foundryims.com/api/v1/storefront/navigation", {
headers: { Authorization: `Bearer ${process.env.FOUNDRY_STOREFRONT_KEY}` },
next: { revalidate: 60 },
});
const nodes = await res.json();
function href(node) {
if (node.type === "LINK") return node.href;
if (node.type === "CATEGORY") return `/c/${node.slug}`;
return null; // GROUP — heading only
}
Everything a menu needs — order, nesting, labels, URLs, counts, whether it should show at all — is in the response. Category slugs are not hand-typed anywhere in your code, so a slug change in Foundry can’t leave a dead menu link behind.
Keeping it fresh
Storefront reads are cached (Cache-Control: public, max-age=60, stale-while-revalidate=300, and a CDN in front), so a menu edit can take a few minutes to appear. For instant updates, subscribe to the catalog.changed webhook: menu edits arrive as a navigation entity, category edits as category. Revalidate your header on receipt.
Two storefronts, one catalog — a worked example
An organization sells the same catalog through two headless stores, Alpha and Beta, each its own HEADLESS channel with its own storefront key.
- The org has 295 categories. Alpha has 190 of them populated (listings), Beta has 60. Each storefront’s
/storefront/navigationalready differs — no admin work. - Beta wants a four-item header: “Shop” (a group holding a handful of merged category pages), “Brands”, “Deals”, and “Blog”. Beta’s channel gets a custom menu: seed, delete what it doesn’t need, add the links. Alpha stays on the global tree.
- A category with products on Alpha but none on Beta is hidden from Beta’s menu automatically and stays in Alpha’s.
- Beta later renames “Consumer Electronics” to “Gadgets” — the URL stays
/consumer-electronicsunless someone edits the slug on purpose. - Beta’s Navigation tab warns that “Camping Stoves” (12 listed products) isn’t reachable from its four-item menu; the team adds it under “Shop”.
Related
- Categories & brands — the global tree, slugs, descriptions, sort order
- SEO metadata — titles, descriptions, and per-channel overrides
- Storefront API — the full endpoint reference
- Webhook events —
catalog.changedand cache invalidation