Navigation and Breadcrumbs
Goal
Load a main and a footer navigation, resolve which entity the current URL points at, and render a breadcrumb trail. The important part is that these are three unrelated pieces of state: the navigation is cached per type, the navigation context only holds what a separate resolver already fetched, and the breadcrumb trail is a shared array nothing fills for you.
Shopware Flow
readNavigation post /navigation/{activeId}/{rootId} names its path parameters activeId and rootId, and the schema types both as string | NavigationType. useNavigation({ type }) passes whatever it is given straight into both of them: a navigation type such as main-navigation, or a category id to load that sub-tree. The response is a bare array of categories rather than a search result.
Resolving a URL and holding the resolution are two different composables. useNavigationSearch().resolvePath() issues readSeoUrl post /seo-url and returns a SeoUrl; useNavigationContext() stores that value and never requests anything. Breadcrumbs go one step further: readBreadcrumb get /breadcrumb/{id} exists and no composable calls it. useBreadcrumbs().buildDynamicBreadcrumbs() takes that operation's response as its argument, so the page has to invoke the request itself.
Step 1
Layout: Load one navigation
useNavigation is created per type and passes that string into both path parameters, so main-navigation is sent where an id would be expected — a category id works there too and loads that sub-tree. The layout wraps the load in useAsyncData so the tree is server-rendered.
- Code
useNavigation({ type: 'footer-navigation' })- State
- swNavigation-<type>
- Types
- NavigationType
Read the diagram from left to right:
- The layout calls
useNavigation({ type })once per navigation and loads it insideuseAsyncData. readNavigation post /navigation/{activeId}/{rootId}returns a category array, cached underswNavigation-<type>.- The catch-all route calls
resolvePath(path), which asks/seo-urlwhich entity that path belongs to. useNavigationContext(ref(seoUrl))holds the answer and exposes it asrouteNameandforeignKey.- The page fetches the category for that id, passing listing criteria because a category page is also a listing.
- The page invokes
readBreadcrumb get /breadcrumb/{id}itself and hands the response tobuildDynamicBreadcrumbs, which writes the shared trail with router-ready paths. - The layout renders the tree from the ref it loaded, and a breadcrumb component reads
breadcrumbsfrom the composable.
You do not need to reload the navigation on every route change, and you do not need to resolve a category link yourself. The tree is cached per type for the lifetime of the provide scope, which is why the layout loads it and pages do not, and every category in it already carries the SEO URL getCategoryRoute turns into a router target.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Load a navigation | loadNavigationElements({ depth: 2 }) | POST /navigation/{activeId}/{rootId} | readNavigation body |
| Resolve the path | resolvePath(route.path) | POST /seo-url | readSeoUrl response |
| Fetch the category | useCategorySearch().search(navigationId) | POST /category/{navigationId} | readCategory body |
| Fetch the breadcrumbs | invoke("readBreadcrumb get /breadcrumb/{id}") | GET /breadcrumb/{id} | readBreadcrumb response |
| Store the trail | buildDynamicBreadcrumbs(response.data) | none | Breadcrumb |
| Append one breadcrumb | pushBreadcrumb({ name, path }) | none | Breadcrumb |
The breadcrumb request is the only row without a composable. referrerCategoryId picks the trail when a product is reachable through several categories. type is the one to read the edge case on: its documented default of product does not mean a category page has to override it.
The tooltip on the last two rows shows Schemas["Breadcrumb"], the full wire shape. pushBreadcrumb is looser than that: it also accepts a bare { name, path }, which is what you hand it for a crumb you build yourself.
The three POST rows are the default. Under cacheableReads the navigation, the path resolution and the category read each switch to their GET variant, so a proxy can cache them.
Composables
Pick by what the composable is about — a tree, a path, an entity, or the trail:
| Composable | Scope | Reach for it when |
|---|---|---|
useNavigation | one navigation tree | rendering a main, footer or service navigation from navigationElements |
useNavigationSearch | one path | turning a URL into a SeoUrl with resolvePath() in a catch-all route |
useNavigationContext | the resolved route | reading navigationContext, routeName or foreignKey for the current page |
useCategorySearch | one category by id | fetching a category with search(), including the CMS associations a page needs |
useCategory | the category already loaded | injecting it deeper in the tree instead of threading it through props |
useBreadcrumbs | the shared trail | writing or rendering breadcrumbs anywhere below the component that provided it |
useShopwareContext | the raw client | invoking an operation no composable wraps, such as readBreadcrumb |
useNavigation exposes navigationElements and loadNavigationElements, and useNavigationContext the three values in the row above. useBreadcrumbs is the one with a surface worth breaking down:
- Read —
breadcrumbs, the shared trail. - Write —
buildDynamicBreadcrumbs(response)replaces the trail from areadBreadcrumbresponse;pushBreadcrumb(breadcrumb)appends a single{ name, path }. - Reset —
clearBreadcrumbs()empties it.
Six things the generated reference will not tell you:
loadNavigationElements(criteria)takes the POST body, not a plain criteria object:depthandbuildTreesit alongside the criteria fields. It also swallows its own errors — it sets the shared array to[], logs, and resolves.navigationElementsis typedNavigationRouteResponse | null, and the shared ref starts as[].useNavigationcloses over that ref at call time, so the layout's ownnavigationElementskeeps pointing at it even after the layout re-provides theuseAsyncDataref under the same key. Descendants read the re-provided ref; the layout has to render from itsuseAsyncDatadata instead.resolvePath("/")returns early with the sales channel'snavigationCategoryIdand never touches the Store API. Every other path costs one/seo-urlrequest, with a technical-path fallback derived locally when nothing matches.useNavigationContextis typed to take aRef<SeoUrl | null>, butresolvePathresolves to a plain value, so it has to be wrapped:useNavigationContext(ref(seoUrl)). TheRefis only a type requirement —useContextimmediately doesref(unref(context)), which snapshots the value, so acomputedpassed in does not keep the context in sync either. Calling it with no argument injects whatever a component above provided, which in practice means the catch-all route. Off that route nothing is provided and nothing throws:routeNameisundefinedandforeignKeyis"". Either way it issues no request.buildDynamicBreadcrumbsisasyncbut does no I/O — it maps the response you already fetched.breadcrumbsisundefined, not[], until the first write, so guard withbreadcrumbs?.length.useCategory(categoryResponse)provides the category anduseCategory()injects it, but the injecting call throws aContextErrorwhen nothing was provided above it.CmsElementCategoryNavigationrelies on the page having made that call. What you pass in is copied, not aliased, so a laterrefresh()of the source ref does not reach the consumers below.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type the navigation criteria, the resolved URL, the breadcrumb response, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type NavigationBody =
operations["readNavigation post /navigation/{activeId}/{rootId}"]["body"];
type BreadcrumbResponse =
operations["readBreadcrumb get /breadcrumb/{id}"]["response"];
type NavigationRouteResponse = Schemas["NavigationRouteResponse"];
type NavigationType = Schemas["NavigationType"];
type Category = Schemas["Category"];
type Breadcrumb = Schemas["Breadcrumb"];
type SeoUrl = Schemas["SeoUrl"];NavigationRouteResponse is declared as Category[] and BreadcrumbResponse as Breadcrumb[], so both are iterable directly. NavigationType is the enum of the three accepted type strings. Breadcrumb here is the Store API shape, which requires categoryId, type, translated and apiAlias alongside name and path; pushBreadcrumb accepts a looser { name, path } as well.
Minimal Vue Example
The layout owns the navigation and renders it once. The page below owns the category and the breadcrumb trail.
<script setup lang="ts">
import { ApiClientError } from "@shopware/api-client";
import type { Ref } from "vue";
import type { Schemas } from "#shopware";
const props = defineProps<{ navigationId: string }>();
const { search } = useCategorySearch();
const { routeName, foreignKey } = useNavigationContext();
const { breadcrumbs, buildDynamicBreadcrumbs, clearBreadcrumbs } =
useBreadcrumbs();
const { apiClient } = useShopwareContext();
const router = useRouter();
const { data: categoryResponse, error } = await useAsyncData(
`categoryPage${props.navigationId}`,
async () => {
try {
return await search(props.navigationId, { withCmsAssociations: true });
} catch (searchError) {
if (searchError instanceof ApiClientError && searchError.status === 404) {
throw createError({
statusCode: 404,
statusMessage: "Category not found",
});
}
throw searchError;
}
},
);
if (error.value) {
throw error.value;
}
if (!categoryResponse.value) {
throw createError({ statusCode: 404, statusMessage: "Category not found" });
}
const { category } = useCategory(categoryResponse as Ref<Schemas["Category"]>);
const isLoadingBreadcrumbs = ref(false);
const breadcrumbsError = ref<string | null>(null);
const breadcrumbRequest = import.meta.client
? new AbortController()
: undefined;
clearBreadcrumbs();
if (import.meta.client) {
const removeGuard = router.beforeEach((to, from) => {
if (to.fullPath !== from.fullPath) breadcrumbRequest?.abort();
});
onBeforeUnmount(() => {
breadcrumbRequest?.abort();
removeGuard();
});
}
onMounted(async () => {
isLoadingBreadcrumbs.value = true;
try {
const response = await apiClient.invoke(
"readBreadcrumb get /breadcrumb/{id}",
{
pathParams: { id: props.navigationId },
fetchOptions: { signal: breadcrumbRequest?.signal },
},
);
await buildDynamicBreadcrumbs(response.data);
} catch (requestError) {
if (breadcrumbRequest?.signal.aborted) return;
breadcrumbsError.value = "The breadcrumb trail could not be loaded.";
console.error("[CategoryPage]", requestError);
} finally {
isLoadingBreadcrumbs.value = false;
}
});
</script>
<template>
<nav aria-label="Breadcrumb">
<p v-if="isLoadingBreadcrumbs" aria-live="polite">
Loading the breadcrumb trail…
</p>
<p v-else-if="breadcrumbsError" role="alert">{{ breadcrumbsError }}</p>
<ol v-else-if="breadcrumbs?.length">
<li
v-for="(breadcrumb, index) in breadcrumbs"
:key="`${breadcrumb.name}-${index}`"
>
<NuxtLink
v-if="breadcrumb.path && index < breadcrumbs.length - 1"
:to="breadcrumb.path"
>
{{ breadcrumb.name }}
</NuxtLink>
<span
v-else
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
>
{{ breadcrumb.name }}
</span>
</li>
</ol>
</nav>
<h1>{{ category.translated?.name ?? category.name }}</h1>
<p v-if="routeName">
This URL resolves to {{ routeName }} with the id {{ foreignKey }}.
</p>
</template>The page reads useNavigationContext() with no argument, which works because the catch-all route rendered it and provided the resolved SeoUrl above it. On a route that provides nothing the call does not throw — routeName comes back undefined and foreignKey an empty string.
The navigation lives one level up. The layout renders from the useAsyncData ref rather than from navigationElements, because useNavigation closed over its own ref before the re-provide and that ref stays empty on the client:
<script setup lang="ts">
import { getCategoryRoute } from "@shopware/helpers";
const { loadNavigationElements } = useNavigation();
const { data: mainNavigation } = await useAsyncData("mainNavigation", () =>
loadNavigationElements({ depth: 2 }),
);
provide("swNavigation-main-navigation", mainNavigation);
</script>
<template>
<nav aria-label="Main navigation">
<p v-if="!mainNavigation?.length" role="alert">
The navigation is not available.
</p>
<ul v-else>
<li v-for="item in mainNavigation" :key="item.id">
<NuxtLink :to="getCategoryRoute(item)">
{{ item.translated?.name ?? item.name }}
</NuxtLink>
<ul v-if="item.children?.length">
<li v-for="child in item.children" :key="child.id">
<NuxtLink :to="getCategoryRoute(child)">
{{ child.translated?.name ?? child.name }}
</NuxtLink>
</li>
</ul>
</li>
</ul>
</nav>
<slot />
</template>Keep the navigation landmark in the layout only. A page that renders its own <nav aria-label="Main navigation"> adds a second landmark with the same accessible name, nested inside <main>.
The page that owns the entity is what fills the trail. A product page does the same thing with referrerCategoryId in the query, then calls pushBreadcrumb with the product's own name and SEO path — the starter appends that last crumb itself rather than reading it from the response.
State And Session
Each navigation type gets its own shared value under swNavigation-<type>, so a main and a footer navigation coexist without overwriting each other. Two useNavigation({ type: "main-navigation" }) calls in the same tree share one array — which is the point, and also why a second loadNavigationElements call replaces what the first one loaded.
That sharing is inject with a provide fallback, so it only reaches components below the one that provided. The starter layout goes one step further and re-provides the useAsyncData ref under swNavigation-main-navigation after loading, which is what carries the server-rendered tree into the client. Drop that line and nothing refetches — useAsyncData short-circuits during hydration, so the tree is lost rather than reloaded and the navigation stays empty.
useBreadcrumbs keeps a single swBreadcrumb value, and it is not application-wide. Whichever component calls useBreadcrumbs() highest in the tree — with no argument is enough — creates the ref and provides it, and every writer and reader has to sit below that component. It starts out undefined and becomes an array on the first write.
Where that call sits decides the lifetime. In the starter every caller is a page-level component and the breadcrumb bar is their child, so the ref is created and destroyed with the page: the trail does not survive a route change. Call useBreadcrumbs() in a layout or above NuxtPage instead and the same ref outlives every navigation — which is the arrangement that makes a stale trail, and an uncancelled breadcrumb request, something you have to handle.
Every request here is scoped by the sw-context-token, so the language in that context decides which translations come back; the Session Context recipe covers how that token is seeded and refreshed. What matters for the navigation is the consequence: a language switch invalidates the tree you already loaded, which is one reason it ends in a full page load rather than a reactive update. A currency switch does not, because the navigation carries category names, not prices.
useNavigationContext holds route data rather than fetching it. It wraps the SeoUrl that useNavigationSearch().resolvePath() produced, and routeName is what a catch-all route branches on to decide which page component to render.
Edge Cases
readNavigationaccepts either a navigation type or a category id inactiveIdandrootId, anduseNavigation({ type })forwards either verbatim.CmsElementCategoryNavigationpasses the active category's id to load a sub-tree, which then lives underswNavigation-<categoryId>and never collides with the main navigation.loadNavigationElementscatches its own errors, sets the shared array to[]and logs. A failed load is indistinguishable from an empty navigation.buildTreeis described as choosing between a tree and a flat list but is declared in the schema as an array of objects. Verify the shape against your Shopware version before relying on it.- On the cacheable GET variant the composable strips
buildTreeanddepthout of the criteria and sends them as dedicated query parameters. On the POST variant they stay in the body. A hand-rolled request has to match the variant it uses. depthcounts the levels below the root, sodepth: 1returns the top level plus its children anddepth: 2adds one more; omitting it behaves likedepth: 2. Only the nesting changes — the number of root elements is identical at every depth, so comparing two depths by the length ofnavigationElementsshows nothing. A navigation rendered three levels deep withdepth: 1shows no grandchildren and no error.resolvePathreturnsnullwhen/seo-urlmatches nothing and no technical-path fallback applies. A catch-all route that does not checkforeignKeyrenders a page component with an empty id.resolvePathrejects rather than returningnullwhen the request itself fails — the/seo-urlcall is not wrapped.nullmeans "no match", an exception means "could not ask". Map onlynullto a 404, or a Store API blip surfaces as an unhandled error page.useCategorySearch().search()rejects on any non-2xx. Reading onlydatafromuseAsyncDataand treating a null value as "not found" turns every 500 and timeout into a 404 — cached for as long as yourisrrule says. Readerrorand rethrow anything that is not a real 404.readBreadcrumb get /breadcrumb/{id}has no composable.buildDynamicBreadcrumbsconsumes its response, so forgetting the request leaves the trail empty rather than failing.typeis documented as defaulting toproduct, which reads as though a category page must sendtype: "category". It does not. Against the demo backend onlycategoryis restrictive — it resolves categories and nothing else, while an absent,productor even unrecognised value resolves either kind. That is why a category id returns a byte-identical trail withtypeabsent,categoryorproduct, and why the starter omits it. The parameter bites in the other direction instead:type: "category"on a product id returns an empty array with a200, not an error. Leave it unset unless you are deliberately restricting the lookup.- An id that matches nothing also answers
200with[]. Every failure mode of this endpoint is an empty trail, never a thrown error, so a missing breadcrumb bar is the only symptom you get. - Fetching the trail from
onMountedkeeps it out of the server-rendered HTML. Whatever the template renders while the request is pending is what ships in the markup and what a crawler indexes, so start a loading flag atfalseand raise it insideonMountedrather than initialising it totrue. If breadcrumbs must be in the initial markup, move the request into the sameuseAsyncDataas the category and accept the extra server round trip. - Nothing cancels the breadcrumb request for you.
buildDynamicBreadcrumbsreplaces the trail wholesale, so once the ref outlives the page a response that lands after the user has navigated away overwrites the new page's trail. Pass anAbortControllersignal and abort it fromrouter.beforeEachandonBeforeUnmount, as the starter does. buildDynamicBreadcrumbsprefixes everypathwith/. Passing an already-absolute path produces//path.pushBreadcrumbmutates the shared array in place. Where the ref outlives the page, calling it on every route change withoutclearBreadcrumbs()grows the trail indefinitely.- A stale trail is only possible where the ref sits above the page. The starter guards it four ways:
FrontendNavigationPageandFrontendDetailPagecallclearBreadcrumbs()in setup, the breadcrumb component clears it again from arouter.beforeEachguard, the breadcrumb request is aborted from that guard and fromonBeforeUnmount, and the ref is page-scoped so it dies with the page anyway. Only the abort covers a request already in flight. readCategory post /category/{navigationId}accepts aProductListingCriteriaand returns aCategory, not a search result. The listing criteria apply to the category's embedded listing.useCategory()throws aContextErrorwhen no category was provided above it. Provide it withuseCategory(categoryRef)on the page.useNavigationalways sendssw-include-seo-urls: true, so the categories carry the URLs a link needs. Do not resolve routes separately.
Common Mistakes
- Do not assume
typehas to be one of the three navigation types. A category id fetches that sub-tree, under its own shared state key. - Do not load the navigation in a page component, and do not load it from
onMounted. UseuseAsyncDatain the layout so it is server-rendered once per type. - Do not treat an empty
navigationElementsas proof the navigation is empty. Errors are swallowed. - Do not expect
useNavigationContextto resolve anything.useNavigationSearch().resolvePath()is what issues the request. - Do not expect
useBreadcrumbsto fetch anything. Invoke the breadcrumb operation yourself. - Do not assume the trail is in the server-rendered HTML when you fetch it from
onMounted. - Do not call
pushBreadcrumbwithout clearing the trail first on a route change. - Do not assume the trail is application-wide. It belongs to the component that called
useBreadcrumbs()highest in the tree. - Do not let a failed category read fall through to a 404. Read
errorfromuseAsyncDataand rethrow anything that is not a real 404. - Do not prefix breadcrumb paths yourself.
buildDynamicBreadcrumbsalready does. - Do not use
useCategory()on a page that has not provided a category. - Do not build category links by hand. Use the SEO URLs the navigation already includes.
Testing Checklist
- The layout issues one
readNavigation post /navigation/{activeId}/{rootId}request per navigation type, during the server render. - The request carries the type string in both path parameters and the
sw-include-seo-urlsheader. - A second
useNavigation({ type })in a child component issues no request and sees the same elements. - A failing navigation load renders the empty state rather than throwing.
depth: 1returns one level of children anddepth: 2returns grandchildren.- A path that no
SeoUrlmatches produces a 404 rather than a page component with an empty id. - A category page fetches the breadcrumbs after mount and fills the shared trail.
- A 500 from
readCategoryrenders a 500, not "Category not found". - A failing
/seo-urlrequest renders an error page rather than a 404. - Navigating away before the breadcrumb request resolves leaves the new page's trail intact.
- The server-rendered HTML contains no breadcrumb loading placeholder.
- Breadcrumb paths start with a single slash and resolve in the router.
- Navigating to a page that sets no breadcrumbs does not leave the previous trail visible.
routeNameandforeignKeyreflect the entity the current URL resolves to.