URL Resolving and SEO URLs
Goal
Serve every shop URL from one catch-all route: resolve the path to an entity, redirect technical URLs to their canonical SEO URL, and render the right page component. The important part is that the resolution is a SeoUrl lookup whose routeName decides the component, and that the home page is resolved without a request at all.
Shopware Flow
readSeoUrl post /seo-url is an entity search over SEO URLs. useNavigationSearch().resolvePath(path) filters it on seoPathInfo for a normal path and on pathInfo for a technical one — /navigation/<id>, /detail/<id> or /landingPage/<id>.
Two paths never reach that request. / is answered from the session context, using the sales channel's navigationCategoryId and a hardcoded frontend.navigation.page route name. And when nothing matches, getRouteFromPathInfo derives a route name and an id from the technical prefix, producing a synthetic SeoUrl rather than a 404.
Step 1
Route: Catch every path
A single catch-all route receives the path. The locale prefix is stripped before anything is resolved, so the API only ever sees the shop path.
- Code
const routePath = route.path.slice(localeRootPath.length)- State
- route path
- Types
Read the diagram from left to right:
- The catch-all route receives the path and strips the locale prefix.
resolvePath("/")synthesises a resolution fromsessionContext.salesChannel.navigationCategoryId.- Any other path is looked up over
/seo-url, filtered onseoPathInfoorpathInfo. - With no match,
getRouteFromPathInfoderives a resolution from the technical prefix, or returnsnull. - A technical path that mapped to a real SEO URL is redirected with a
301. useNavigationContext(seoUrl)providesrouteNameandforeignKey, and the pascal-cased route name is the page component.- That component fetches its own entity — a category, a product or a landing page — from composables instead of keeping its own copy of the resolution.
You do not need one route per page type, and you do not fetch the entity in the catch-all. The routeName on the resolution is what selects the component, which is why frontend.navigation.page becomes FrontendNavigationPage.
Request Flow
| Step | Code | Store API | Type |
|---|---|---|---|
| Resolve the home page | resolvePath("/") | none | SalesChannelContext |
| Resolve a SEO path | resolvePath("/my-category/my-product") | POST /seo-url | readSeoUrl body |
| Read the resolution | routeName, foreignKey | POST /seo-url | readSeoUrl response |
| Fetch a category page | useCategorySearch().search(id, { withCmsAssociations: true }) | POST /category/{navigationId} | readCategory body |
| Fetch a product page | useProductSearch().search(id, { withCmsAssociations: true }) | POST /product/{productId} | readProductDetail body |
| Fetch a landing page | useLandingSearch().search(id, { withCmsAssociations: true }) | POST /landing-page/{landingPageId} | readLandingPage body |
The methods in that column are the defaults. With cacheableReads enabled — vue-starter-template sets it under runtimeConfig.public.shopware, and shopware: { cacheableReads: true } is the equivalent module-option form — resolvePath, useCategorySearch().search and useProductSearch().search call their GET variants instead, with the same criteria compressed into a _criteria query parameter: readSeoUrlGet get /seo-url, readCategoryGet get /category/{navigationId} and readProductDetailGet get /product/{productId}. The filters, the fallback and the redirect are identical; only the transport changes. useLandingSearch has no GET branch and always posts. See Caching for what that buys you.
useUrlResolver().resolveUrl is not part of the resolution chain despite the name. It puts the application's URL prefix in front of a CMS-authored internal navigation link and leaves everything else untouched.
Composables
Pick by scope — how much of the resolution the composable is about:
| Composable | Scope | Reach for it when |
|---|---|---|
useNavigationSearch | any path | turning a URL into a SeoUrl |
useNavigationContext | the resolved SeoUrl | reading routeName or foreignKey anywhere below the route |
useCategorySearch | one category, or a criteria | building the category page a frontend.navigation.page points at |
useProductSearch | one product | building the detail page a frontend.detail.page points at |
useLandingSearch | one landing page | building the frontend.landing.page component |
useUrlResolver | one CMS link | rendering author-written HTML that contains internal links |
useBreadcrumbs | one page's trail | a page component that owns its breadcrumbs, never a layout |
useNavigationSearch is the one the recipe turns on, and it has a single entry point. resolvePath(path) returns Promise<Schemas["SeoUrl"] | null> and branches on the shape of the path:
/— no request. Returns{ routeName: "frontend.navigation.page", foreignKey: navigationCategoryId }from the session context.- A SEO path — one lookup filtered on
seoPathInfo, with the leading slash removed. - A technical path — one lookup filtered on
pathInfo, with a trailing slash removed bynormalizePath. - No match —
getRouteFromPathInfoderives a synthetic resolution from the technical prefix, otherwisenull.
Seven things the generated reference will not tell you:
resolvePath("/")issues no request at all, so it is only as correct as the session context. The id it returns changes with the sales channel, not with the route.useNavigationContext(context)snapshots what you pass it.useContextstoresref(unref(context)), so acomputedhanded to it is read once rather than tracked — which is what you want for a per-navigation resolution, and a trap if you expect it to follow a later change.useNavigationContextissues no requests. It provides thenavigationinjection and exposesnavigationContext,routeNameandforeignKey;foreignKeyfalls back to"", neverundefined. Called without an argument it only injects, and invue-starter-templatethe catch-all is the only thing that seeds it — under an explicit file route you pass theSeoUrlin yourself.useCategorySearchhas two methods and they are not symmetrical.search(categoryId, options)sendssw-include-seo-urls: true;advancedSearch({ query })does not send that header at all.useCategorySearch().searchis the odd one out on associations: it puts the wholecmsAssociationsobject into the body'sassociationsfield, so the CMS tree ends up nested one level deeper.useLandingSearch().searchanduseProductSearch().searchboth sendcmsAssociations.associationsunwrapped. The three are not interchangeable if you build a request by hand.useUrlResolver().resolveUrl(url)prefixes the path, it does not rewrite it. Thesplit("/").slice(1)inside reads like it removes a path segment, but on a path that starts with a slash the element it removes is the empty string in front of it:/en/navigation/123withurlPrefix: "shop"comes back as/shop/en/navigation/123, locale segment intact, which is what the composable's own test pins. Only a path handed in without a leading slash loses a real segment. It also throwsURL Input too longfor input over 2083 characters, andgetUrlPrefix()reads an injectedurlPrefixthat the application provides, not the composable.useBreadcrumbsis scoped, not global. It goes throughuseContext("swBreadcrumb"), which injects an ancestor's ref or, finding none, creates its own and provides it downwards. Nothing above the page components provides it, so each page roots a fresh trail per mount. The Navigation and Breadcrumbs recipe covers what changes when that ref sits higher.
The composables reference is generated from source and lists every member.
Types
Use generated Store API types when you need to type the resolution, the entity requests, or lower-level API client calls:
import type { Schemas, operations } from "#shopware";
type SeoUrlBody = operations["readSeoUrl post /seo-url"]["body"];
type SeoUrlResponse = operations["readSeoUrl post /seo-url"]["response"];
type CachedSeoUrlResponse =
operations["readSeoUrlGet get /seo-url"]["response"];
type LandingPageBody =
operations["readLandingPage post /landing-page/{landingPageId}"]["body"];
type SeoUrl = Schemas["SeoUrl"];
type LandingPage = Schemas["LandingPage"];SeoUrl is the type the whole recipe turns on. routeName selects the page component, foreignKey is the entity id, and seoPathInfo and pathInfo are the two fields the lookup filters on.
Minimal Vue Example
<script setup lang="ts">
import {
getCanonicalPathForTechnicalPath,
isTechnicalPath,
} from "@shopware/helpers";
import { pascalCase } from "scule";
import { useI18n, useLocalePath } from "#imports";
import type { Schemas } from "#shopware";
const { resolvePath } = useNavigationSearch();
const route = useRoute();
const { locale } = useI18n();
const localePath = useLocalePath();
const localeRootPath = `/${locale.value}`;
const routePath =
route.path === localeRootPath
? "/"
: route.path.startsWith(`${localeRootPath}/`)
? route.path.slice(localeRootPath.length)
: route.path;
const isTechnical = isTechnicalPath(routePath);
const { data: seoResult, error: resolveError } = await useAsyncData(
`seo-url:${locale.value}:${routePath}`,
async () => {
if (import.meta.client && !isTechnical) {
const { routeName: stateRouteName, foreignKey: stateForeignKey } =
history.state ?? {};
if (stateRouteName && stateForeignKey) {
return {
routeName: stateRouteName,
foreignKey: stateForeignKey,
} as Schemas["SeoUrl"];
}
}
return await resolvePath(routePath);
},
);
if (resolveError.value) {
throw createError({
statusCode: 503,
statusMessage: `Could not resolve ${routePath}`,
cause: resolveError.value,
fatal: true,
});
}
const canonicalPath = getCanonicalPathForTechnicalPath(
routePath,
seoResult.value,
);
const canonicalRedirectTarget = canonicalPath
? localePath({ path: canonicalPath, query: route.query })
: null;
if (canonicalRedirectTarget) {
await navigateTo(canonicalRedirectTarget, {
redirectCode: 301,
replace: true,
});
}
if (!canonicalRedirectTarget && !seoResult.value?.foreignKey) {
throw createError({
statusCode: 404,
statusMessage: `No data fetched from API for ${routePath}`,
});
}
const { routeName, foreignKey } = useNavigationContext(
ref((canonicalRedirectTarget ? null : seoResult.value) ?? null),
);
const componentName = routeName.value ? pascalCase(routeName.value) : null;
const resolved = componentName ? resolveComponent(componentName) : null;
const pageComponent = resolved === componentName ? null : resolved;
if (!canonicalRedirectTarget && !pageComponent) {
throw createError({
statusCode: 404,
statusMessage: `No page component for ${routePath}`,
});
}
</script>
<template>
<component
:is="pageComponent"
v-if="pageComponent"
:navigation-id="foreignKey"
/>
</template>Four things that route depends on and the code does not show:
- The target components must be registered
global: true.resolveComponentresolves against the app's global component registry, not against Nuxt's compile-time auto-imports, so aFrontendNavigationPage.vuein plainapp/components/is invisible to it.vue-starter-templateregistersapp/components/globalwithpathPrefix: falseandglobal: trueinnuxt.config.ts. - The session context must already be seeded.
resolvePath("/")reads it instead of requesting anything, so the root has to await the context before this route renders.vue-starter-templatedoes it inapp.vue. navigateTodoes not halt<script setup>. Everything after the redirect still runs, on the server and on the client, which is why every later step is gated on!canonicalRedirectTarget. An unguarded request orthrowbelow it fires work against a response nobody will see — and at301a wrong answer is cached permanently.- The component is remounted per path. Every derived value is a plain
const, which is correct only because Nuxt's default page key changes with the route. Akeepaliveor a custompage-keyfreezes them on the first path resolved.
The page component receives only the foreignKey. It is the component's job to fetch its own entity with withCmsAssociations: true: in vue-starter-template, FrontendNavigationPage reads a category, FrontendDetailPage reads a product through useProductSearch, and FrontendLandingPage reads a landing page. Each also owns its breadcrumbs — the first two call clearBreadcrumbs() before building their own trail, and FrontendLandingPage passes the CMS breadcrumbs to useBreadcrumbs().
State And Session
The resolution is provided, not fetched twice. useNavigationContext(seoUrl) writes the navigation injection, and every component below reads routeName and foreignKey from it without another lookup.
resolvePath("/") depends on the session context being loaded. It reads sessionContext.salesChannel.navigationCategoryId, so the home page cannot resolve before the root has seeded the context — and the id it returns changes with the sales channel, not with the route.
The lookup itself carries sw-context-token like any Store API call, but it changes nothing on the session. Whether it travels as a POST body or as a _criteria query parameter is decided once by cacheableReads in nuxt.config.ts, not per call.
useUrlResolver().getUrlPrefix() reads an injected urlPrefix with "" as its default. Nothing in the composable provides it; the application does — vue-starter-template calls provide("urlPrefix", prefix) in app.vue, which is how the locale prefix reaches CMS components that render internal links.
Edge Cases
resolvePath("/")issues no request. Debugging a wrong home page means looking at the session context, not at the SEO URL table.- A SEO path is filtered with the leading slash removed (
path.substring(1)), a technical path with its trailing slash removed bynormalizePath. Sending the wrong form matches nothing. - With
cacheableReads: truethe lookup isreadSeoUrlGet get /seo-url, so a network assertion or a proxy rule written againstPOST /seo-urlsees nothing. getRouteFromPathInforecognises exactly three prefixes:/navigation/,/detail/and/landingPage/. It returnsnullwhen the remainder is empty or contains another slash.- The fallback resolution is synthetic: it has a
routeNameand aforeignKeyand noseoPathInfo.getCanonicalPathForTechnicalPathreturnsnullfor it, so no redirect happens. getCanonicalPathForTechnicalPathalso returnsnullfor SEO paths and for a mapping whose target is itself technical. Only a genuine technical-to-SEO mapping produces a redirect.- The redirect uses
301. Getting the condition wrong caches the wrong target in browsers and CDNs. routeNameis pascal-cased into a component name, andresolveComponentonly finds components registeredglobal: true. It returns the name string rather than throwing when nothing matches, which is why the example compares the result against the name and turns a miss into a404.resolveUrlthrowsURL Input too longfor input over 2083 characters. That is a deliberate guard against a polynomial regular expression, not a validation error to surface.resolveUrlonly touches URLs matching[a-zA-Z0-9]+/navigation/[a-zA-Z0-9]+and returns everything else unchanged — including a/detail/<id>link, and including/navigation/<id>itself, which has no segment before the slash for the pattern to match and so never gets the prefix.- The
history.stateshortcut keys off the field, not its provenance:[...all].vuetakes it whenever a client-side navigation to a non-technical path carrieshistory.state.routeName, and readsforeignKeyalongside it without requiring it. A plain<NuxtLink to="/my-category">therefore still takes the lookup, while anything that writes that state skips it — including a link that setsrouteNamealone, whose resolution then trips the404guard on click and resolves fine on reload.getProductRouteandgetCategoryRouteare what write the pair in practice, and both can emit arouteNamewith an undefinedforeignKey—getProductRoutetakes an optional product, andgetCategoryRoutereadsinternalLinkfor aproductorlanding_pagelink. That is why the example above guards on both fields. - Nothing on this path carries a timeout.
resolvePathtakes no signal, so a Store API that accepts the connection and never answers hangs the render until the platform kills it. SetruntimeConfig.apiClientConfig.timeoutif you want a bound.
Common Mistakes
- Do not send the locale-prefixed path to
resolvePath. Strip the prefix first. - Do not expect a SEO URL row for the home page. It is synthesised.
- Do not treat a
nullresolution as a server error. It means nothing matched and no technical fallback applied. - Do not redirect on every technical path. Check
getCanonicalPathForTechnicalPathfirst — it returnsnullfor synthetic fallbacks. - Do not use a
302for the canonical redirect. The helper's contract is a permanent mapping. - Do not add one Nuxt route per page type. The
routeNameselects the component. - Do not fetch the entity in the catch-all route. Pass the
foreignKeydown. - Do not assume the lookup is a POST. Read
cacheableReadsbefore asserting on the request in a test or a cache rule. - Do not surface
URL Input too longto the customer. It is an internal guard. - Do not rely on
resolveUrlto prefix arbitrary links. It handles navigation links only. - Do not let a failed lookup fall into the
404branch. ReaderrorfromuseAsyncDatafirst and answer with a5xx, or a backend outage returns404for the whole catalogue and crawlers de-index it. - Do not put the resolver's target components in plain
app/components/.resolveComponentonly sees a path registeredglobal: true. - Do not enter the
history.stateshortcut onrouteNamealone. Without aforeignKeyit yields a resolution that fails the404guard on click but works on reload.
Testing Checklist
- Opening
/resolves tofrontend.navigation.pagewithout any/seo-urlrequest. - A SEO path issues one
/seo-urllookup filtered onseoPathInfowithout the leading slash. - A technical path issues one
/seo-urllookup filtered onpathInfo. - The lookup uses the GET route when
cacheableReadsis enabled and the POST route when it is not. - A technical path with a SEO mapping redirects once with a
301to the canonical path. - A technical path without a SEO mapping renders the page directly, with no redirect.
- An unknown path throws a 404 rather than rendering an empty component.
- A client-side link whose history state carries both
routeNameandforeignKeyissues no lookup. - A failing Store API answers with a
5xx, not a404. - The locale prefix is preserved in the redirect target and absent from the API request.
- A page component that sets no breadcrumbs does not inherit the previous page's trail.