Skip to content

URL Resolving and SEO URLs

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:

  1. The catch-all route receives the path and strips the locale prefix.
  2. resolvePath("/") synthesises a resolution from sessionContext.salesChannel.navigationCategoryId.
  3. Any other path is looked up over /seo-url, filtered on seoPathInfo or pathInfo.
  4. With no match, getRouteFromPathInfo derives a resolution from the technical prefix, or returns null.
  5. A technical path that mapped to a real SEO URL is redirected with a 301.
  6. useNavigationContext(seoUrl) provides routeName and foreignKey, and the pascal-cased route name is the page component.
  7. 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 ​

StepCodeStore APIType
Resolve the home pageresolvePath("/")noneSalesChannelContext
Resolve a SEO pathresolvePath("/my-category/my-product")POST /seo-urlreadSeoUrl body
Read the resolutionrouteName, foreignKeyPOST /seo-urlreadSeoUrl response
Fetch a category pageuseCategorySearch().search(id, { withCmsAssociations: true })POST /category/{navigationId}readCategory body
Fetch a product pageuseProductSearch().search(id, { withCmsAssociations: true })POST /product/{productId}readProductDetail body
Fetch a landing pageuseLandingSearch().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:

ComposableScopeReach for it when
useNavigationSearchany pathturning a URL into a SeoUrl
useNavigationContextthe resolved SeoUrlreading routeName or foreignKey anywhere below the route
useCategorySearchone category, or a criteriabuilding the category page a frontend.navigation.page points at
useProductSearchone productbuilding the detail page a frontend.detail.page points at
useLandingSearchone landing pagebuilding the frontend.landing.page component
useUrlResolverone CMS linkrendering author-written HTML that contains internal links
useBreadcrumbsone page's traila 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 by normalizePath.
  • No match — getRouteFromPathInfo derives a synthetic resolution from the technical prefix, otherwise null.

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. useContext stores ref(unref(context)), so a computed handed 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.
  • useNavigationContext issues no requests. It provides the navigation injection and exposes navigationContext, routeName and foreignKey; foreignKey falls back to "", never undefined. Called without an argument it only injects, and in vue-starter-template the catch-all is the only thing that seeds it — under an explicit file route you pass the SeoUrl in yourself.
  • useCategorySearch has two methods and they are not symmetrical. search(categoryId, options) sends sw-include-seo-urls: true; advancedSearch({ query }) does not send that header at all.
  • useCategorySearch().search is the odd one out on associations: it puts the whole cmsAssociations object into the body's associations field, so the CMS tree ends up nested one level deeper. useLandingSearch().search and useProductSearch().search both send cmsAssociations.associations unwrapped. The three are not interchangeable if you build a request by hand.
  • useUrlResolver().resolveUrl(url) prefixes the path, it does not rewrite it. The split("/").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/123 with urlPrefix: "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 throws URL Input too long for input over 2083 characters, and getUrlPrefix() reads an injected urlPrefix that the application provides, not the composable.
  • useBreadcrumbs is scoped, not global. It goes through useContext("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:

readSeoUrl body readSeoUrl response readSeoUrlGet response readLandingPage body SeoUrl Category LandingPage
ts
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 ​

Minimal catch-all route
vue
<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. resolveComponent resolves against the app's global component registry, not against Nuxt's compile-time auto-imports, so a FrontendNavigationPage.vue in plain app/components/ is invisible to it. vue-starter-template registers app/components/global with pathPrefix: false and global: true in nuxt.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-template does it in app.vue.
  • navigateTo does 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 or throw below it fires work against a response nobody will see — and at 301 a 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. A keepalive or a custom page-key freezes 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 by normalizePath. Sending the wrong form matches nothing.
  • With cacheableReads: true the lookup is readSeoUrlGet get /seo-url, so a network assertion or a proxy rule written against POST /seo-url sees nothing.
  • getRouteFromPathInfo recognises exactly three prefixes: /navigation/, /detail/ and /landingPage/. It returns null when the remainder is empty or contains another slash.
  • The fallback resolution is synthetic: it has a routeName and a foreignKey and no seoPathInfo. getCanonicalPathForTechnicalPath returns null for it, so no redirect happens.
  • getCanonicalPathForTechnicalPath also returns null for 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.
  • routeName is pascal-cased into a component name, and resolveComponent only finds components registered global: 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 a 404.
  • resolveUrl throws URL Input too long for input over 2083 characters. That is a deliberate guard against a polynomial regular expression, not a validation error to surface.
  • resolveUrl only 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.state shortcut keys off the field, not its provenance: [...all].vue takes it whenever a client-side navigation to a non-technical path carries history.state.routeName, and reads foreignKey alongside 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 sets routeName alone, whose resolution then trips the 404 guard on click and resolves fine on reload. getProductRoute and getCategoryRoute are what write the pair in practice, and both can emit a routeName with an undefined foreignKey — getProductRoute takes an optional product, and getCategoryRoute reads internalLink for a product or landing_page link. That is why the example above guards on both fields.
  • Nothing on this path carries a timeout. resolvePath takes no signal, so a Store API that accepts the connection and never answers hangs the render until the platform kills it. Set runtimeConfig.apiClientConfig.timeout if 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 null resolution as a server error. It means nothing matched and no technical fallback applied.
  • Do not redirect on every technical path. Check getCanonicalPathForTechnicalPath first — it returns null for synthetic fallbacks.
  • Do not use a 302 for the canonical redirect. The helper's contract is a permanent mapping.
  • Do not add one Nuxt route per page type. The routeName selects the component.
  • Do not fetch the entity in the catch-all route. Pass the foreignKey down.
  • Do not assume the lookup is a POST. Read cacheableReads before asserting on the request in a test or a cache rule.
  • Do not surface URL Input too long to the customer. It is an internal guard.
  • Do not rely on resolveUrl to prefix arbitrary links. It handles navigation links only.
  • Do not let a failed lookup fall into the 404 branch. Read error from useAsyncData first and answer with a 5xx, or a backend outage returns 404 for the whole catalogue and crawlers de-index it.
  • Do not put the resolver's target components in plain app/components/. resolveComponent only sees a path registered global: true.
  • Do not enter the history.state shortcut on routeName alone. Without a foreignKey it yields a resolution that fails the 404 guard on click but works on reload.

Testing Checklist ​

  • Opening / resolves to frontend.navigation.page without any /seo-url request.
  • A SEO path issues one /seo-url lookup filtered on seoPathInfo without the leading slash.
  • A technical path issues one /seo-url lookup filtered on pathInfo.
  • The lookup uses the GET route when cacheableReads is enabled and the POST route when it is not.
  • A technical path with a SEO mapping redirects once with a 301 to 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 routeName and foreignKey issues no lookup.
  • A failing Store API answers with a 5xx, not a 404.
  • 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.
Was this page helpful?
UnsatisfiedSatisfied
Be the first to vote!
0.0 / 5  (0 votes)