Shopware SEO is usually explained as a checklist: adjust your SEO URLs, submit a sitemap, write meta titles, done. The problem with that framing is not that it is wrong — it is that it changes nothing. It tells you what to configure, but never what your shop actually ships to Google afterwards. That gap is where the real problems live.
So instead of summarising the documentation, we measured. The subject is a production Shopware 6 store we maintain: Shopware 6.7.12.2, 15,562 SEO URLs in the database, 2,604 products, ten sales channels, grown since 2021. Every number below comes from a query against that database or an HTTP request against the live shop. On top of that, every claim about “how Shopware behaves by default” was verified against a near-stock Shopware demo installation — because otherwise there is no way to separate what Shopware does from what a paid plugin does.
The headline finding: two of the three biggest issues were invisible as errors. No red field in the admin, no warning, no log entry. They only surface when you hold the shipped HTML against the configuration.
The short version
| What we checked | Result on the live store |
|---|---|
| Total SEO URLs | 15,562 |
| Canonical and active | 9,212 |
| Old URLs per product (average) | 5.36 — maximum: 32 |
| Do old URLs redirect? | 25 of 25 with 301, single hop |
| URLs flagged as deleted | 12 of 12 with 404 |
| Canonical URLs whose product is gone | 38 of 775 (4.9%) — return 404 |
| Are those 38 in the sitemap? | 0 of 38 — the sitemap is clean |
| Sitemap sample | 20 of 20 returned 200 |
| hreflang in the HTML | 0 tags — despite AT and CH storefronts |
hreflang_active in the database | 0 across all 10 sales channels |
| Product page HTML size | 621 KB decoded / 84 KB transferred |
| Stock Shopware for comparison | 194 KB decoded / 27 KB transferred |
Three of those rows deserve their own section, because each one dismantles a claim that appears in almost every Shopware SEO article.
SEO URLs in Shopware: how the table really works
Shopware stores human-readable URLs in the seo_url table. Each row maps a readable address (seo_path_info) to an internal route (path_info). Two columns decide between 301, 404 and 200: is_canonical and is_deleted.
Here is the distribution on the measured store:
| Route | Rows | Canonical | Flagged deleted |
|---|---|---|---|
frontend.detail.page (products) | 13,964 | 8,347 | 6,433 |
frontend.navigation.page (categories) | 1,117 | 564 | 484 |
| Blog detail pages (plugin) | 444 | 272 | 0 |
frontend.landing.page | 37 | 29 | 14 |
Note the ratio: 2,604 products carry 13,964 product URL rows. On average every product drags 5.36 addresses behind it, one of them 32. That is by design — Shopware keeps old addresses so it can redirect them. But it explains why this table is by far the largest body of SEO data in any mature store.
The trap that caught us first
Our initial query was, in effect, “count all non-canonical URLs” — WHERE is_canonical = 0. The result: 0 rows. A clean, plausible number that would have meant there are no redirecting URLs at all.
The number was wrong, and not marginally. The real figure is 6,350. The reason sits in Shopware’s own source, in SeoUrlPersister.php:
$insert['is_canonical'] = ($seoUrl['isCanonical'] ?? true) ? 1 : null;
Shopware writes either 1 or NULL into that column — never 0. And in SQL, NULL = 0 is not false but unknown, so the row silently drops out of the result set without anything looking broken.
That is the transferable lesson here, well beyond Shopware: a query returning an empty result looks exactly like a query correctly reporting “nothing found.” Anyone filtering on is_canonical = 0 gets a reassuring zero every single time. The correct predicate is is_canonical IS NULL.
What separates a 301 from a 404
Once the numbers were right, we tested behaviour instead of assuming it. We pulled 25 random non-canonical, non-deleted product URLs belonging to the correct sales channel and requested them live:
Result: 25 of 25 returned 301 — each in exactly one hop straight to the target, no chains. For example:
/Flauschtuecher-Bunt1-12er-Set/4464089104463
→ 301 → /flauschi-mikrofasertuecher-bunt-12tlg./324284 → 200
Then the same test against 12 URLs flagged as deleted: 12 of 12 returned 404.
That establishes the rule as measured rather than claimed:
is_deleted = 0→ the old URL redirects (301)is_deleted = 1→ the old URL is dead (404)
The setting behind this is core.seo.redirectToCanonicalUrl, which is true on the measured store and evaluated in the core by CanonicalRedirectService.php.
Why a small first sample pointed the wrong way
For the sake of honesty: our very first sample consisted of three URLs, and all three returned 404. From that you would conclude Shopware does not redirect old addresses at all — the exact opposite of the correct finding.
The explanation: all three belonged to other sales channels, and for the shop under test they were additionally flagged as deleted. A seo_path_info exists per sales channel; the same address can be alive in one storefront and dead in another. Only the sample with the correct channel filter produced the clean picture.
Practical consequence for your own audit: always filter on the sales_channel_id of the domain you are testing. Otherwise you are measuring rows that have nothing to do with the domain you requested.
The finding nobody sees: canonical URLs pointing at nothing
Now the most interesting result. We requested 15 random canonical, active product URLs — the ones the shop itself considers valid.
Thirteen returned 200. Two returned 404.
A join against the product table revealed the cause: no product exists for those rows any more. Extrapolated across the channel:
38 of 775 canonical product URLs (4.9%) point to deleted products.
None of them is merely deactivated — the records are gone entirely. The SEO row stayed behind because it was not removed together with the product.
The crucial counter-question — and the good news
This is the point where the dramatic headline would have been easy: “5% of shop URLs are broken.” Before writing that, we asked the only question that actually matters for SEO: are these URLs in the sitemap? Because only then would Google be actively sent to dead pages.
We downloaded the sitemap (sitemap.xml → a gzipped file with 776 URLs) and checked all 38 against it:
0 of 38 appear in the sitemap.
Shopware filters correctly against the product inventory when generating it. As a cross-check in the other direction, we requested 20 random sitemap URLs — 20 of 20 returned 200.
So the finding stands, but shrinks to its true size: this is a database legacy problem, not an indexing problem. It only matters where those URLs are reachable from outside — via old backlinks, internal links, or feeds for price comparison portals. If you build product feeds straight from seo_url instead of from the product inventory, you happily export that 4.9% too.
For your own store, this query surfaces the legacy rows in about a minute:
SELECT s.seo_path_info
FROM seo_url s
LEFT JOIN product p
ON p.id = s.foreign_key
AND p.version_id = UNHEX('0fa91ce3e96a4bc2be4bd9ce752c3425')
WHERE s.is_canonical = 1
AND s.is_deleted = 0
AND s.route_name = 'frontend.detail.page'
AND s.sales_channel_id = UNHEX('YOUR_SALES_CHANNEL_ID')
AND p.id IS NULL;
The version_id filter is mandatory — without it you double-count version snapshots.
Canonicals and pagination: what Shopware does versus what the plugin does
This section matters to anyone who reads SEO advice. We requested category pages with parameters and read what actually ships in the HTML:
| Request on the live store | rel=canonical | robots |
|---|---|---|
/reiniger/ | /reiniger/ | index,follow |
/reiniger/?p=2 | /reiniger/ | noindex,follow |
/reiniger/?order=name-asc | /reiniger/ | index,follow |
/reiniger/?properties=abc | /reiniger/ | noindex,follow |
That looks like exemplary Shopware defaults. It is not Shopware at all.
A look at NavigationPageLoader.php in the core shows the opposite:
if ($request->query->has('p') && $request->query->getInt('p') > 1) {
$canonical .= '?p=' . $request->query->get('p');
}
Shopware appends the page number to the canonical — page 2 therefore points at itself, not at page 1. And the core sets no noindex for pagination anywhere; every setRobots('noindex,follow') hit in the core belongs to cart, checkout, account, wishlist and search.
To confirm rather than assume, we ran the same requests against a near-stock Shopware demo installation:
| Request on stock Shopware | rel=canonical | robots |
|---|---|---|
/CMS/Image/ | /CMS/Image/ | index,follow |
/CMS/Image/?p=2 | /CMS/Image/?p=2 | index,follow |
/CMS/Image/?order=name-asc | /CMS/Image/ | index,follow |
/CMS/Image/?properties=xyz | /CMS/Image/ | index,follow |
The difference is now measured, not guessed. On the store we maintain, the deviating behaviour comes from a paid SEO plugin (DreiscSeoPro) whose RobotsTagSubscriber ends with exactly this:
$page->getMetaInformation()->setRobots($robotsTag);
Why this matters: a great many Shopware SEO articles state that “Shopware automatically sets clean canonicals and noindex for filters and pagination.” For filter parameters that is true — the canonical points at the parameter-free category, and that is core behaviour. For pagination it is false: the core deliberately does the opposite, and its choice happens to match Google’s guidance since rel=next/prev was retired — every page self-references and stays indexable.
If you run an SEO plugin, you should know it overrides that decision. Whether noindex on page 2 is right depends on your store; the mistaken assumption is that it was ever Shopware’s decision.
hreflang: enabled, configured — and still absent
The measured store runs sales channels for Germany, Austria and Switzerland on their own domains. A textbook case for hreflang. In the shipped HTML of a product page we found:
0 hreflang tags.
As a control, the same check against stock Shopware returned 6 tags, including x-default. So the core is perfectly capable.
The cause sits in HreflangLoader.php, in its very first condition:
if (!$salesChannelContext->getSalesChannel()->isHreflangActive()) {
return new HreflangCollection();
}
And in the database:
hreflang_active = 0on all 10 sales channels.
The insidious part is not the disabled switch. It is that the configuration shows hreflang settings set to true elsewhere — productHrefLang, categoryHrefLang, landingpageHrefLang, all enabled. Those belong to a sitemap plugin and control hreflang annotations inside the sitemap, not in the HTML. Search the admin for “hreflang” and you find enabled checkboxes, and conclude hreflang is running.
We checked the sitemap too: 0 occurrences of hreflang or xhtml:link. Both routes are silent.
🔑 The generalisable lesson: two settings sharing one word, living in two places, doing two different things. An “enabled” in the admin is a statement about one setting, not about the result on the page. The only reliable test is the shipped HTML:
curl -s https://your-shop.com/a-product/ | grep -o '<link[^>]*hreflang[^>]*>'
If nothing comes back, you have no hreflang — regardless of how many boxes are ticked.
SEO URL templates: what you can genuinely change
You control URL structure under Settings → SEO via Twig templates. The measured store runs the defaults:
{# Products #}
{{ product.translated.name|lower }}/{{ product.productNumber }}
{# Categories #}
{% for part in category.seoBreadcrumb %}{{ part|lower }}/{% endfor %}
Two things about this are rarely explained.
First: slugification is automatic. You do not need to replace umlauts or handle spaces. Shopware wraps every template in a dedicated escaper (SeoUrlGenerator.php):
$template = '{% autoescape \'' . self::ESCAPE_SLUGIFY . "' %}$template{% endautoescape %}";
That escaper runs slugify() plus rawurlencode(). “Flauschtücher Bunt” becomes flauschtuecher-bunt without any effort on your part.
Second: |lower only affects where you put it. In our measurement, URLs created in 2026 still contain uppercase characters — but only in the trailing part:
handseife-hygiene-seife-500x-haende-waschen-1000-ml/SW10481
The product name is lowercase, the product number is not, because the template has no |lower after productNumber. Across the years the pattern is clear:
| Year | Product URLs created | With uppercase |
|---|---|---|
| 2021 | 4,775 | 2,450 |
| 2022 | 3,874 | 222 |
| 2023 | 4,351 | 67 |
| 2024 | 406 | 20 |
| 2025 | 287 | 0 |
| 2026 | 271 | 27 |
The collapse from 2021 to 2022 marks the switch to |lower in the name segment. The small rise in 2026 is not a regression — just a batch of new articles whose SKUs contain letters.
Casing in URLs is not cosmetic, by the way: web servers treat paths case-sensitively, so /Product/ and /product/ are two addresses. If you want to normalise that, you change the template — and thereby create one new canonical row per product, turning the old one into a 301. With 2,604 products that is 2,604 new rows. This is precisely how you end up at 5.36 addresses per product.
Practical advice: template changes are cheap to make and expensive to repeat. Settle the structure once, then leave it alone. Every further change extends the redirect history, and Google has to revisit every old address to see the redirect.
Structured data: the area that looks surprisingly good
We expected gaps here and found the opposite. A product page ships, among others:
Product, Offer, AggregateRating, Review, Brand, BreadcrumbList, Organization, LocalBusiness, OfferShippingDetails, MerchantReturnPolicy, ShippingDeliveryTime, DefinedRegion
MerchantReturnPolicy and OfferShippingDetails are worth calling out, because Google uses both for product rich results in shopping surfaces, and many shops never emit them.
Also clean: rel=canonical with an absolute URL, a populated <title>, a populated <meta name="description">, and a robots.txt that correctly blocks /checkout, /account and /widgets while listing three sitemaps.
Another one of our own measurement errors, disclosed
Midway through, our check reported “no meta description” on every product page. That would have been a serious finding. It was wrong.
The reason: Shopware renders the tag across multiple lines, roughly like this:
<meta name="description"
content="Microfibre floor cloth for cleanliness right into the corners …">
Our grep looked for <meta name="description" content="…" on a single line and found nothing. A second script that searches across line breaks found the description immediately.
🔑 Also transferable: a checker that expects one specific format does not report “unclear” when the formatting differs — it reports “absent.” Both look identical in the output. If you build SEO checks with grep, validate them against at least one page where you know the element exists.
Performance: the part that genuinely hurts
Core Web Vitals in Shopware are rarely a framework problem and almost always a plugin and theme problem. The direct comparison makes that obvious:
| Live store (product page) | Stock Shopware (listing) | |
|---|---|---|
| HTML decoded | 621,290 B | 193,578 B |
| HTML transferred (brotli) | 83,858 B | 26,848 B |
<script src> | 50 | 26 |
| Stylesheets | 7 | 1 |
| Inline scripts | 74 | 13 |
<img> elements | 42 | 8 |
of which loading="lazy" | 29 | – |
The HTML is roughly 3.2× larger than stock, with nearly twice the script files and 74 inline script blocks.
Two pieces of context so the numbers do not read as worse than they are:
First, compression rescues a lot. 621 KB decoded becomes 84 KB on the wire — a factor of 7.4. Quoting “621 KB of HTML” without that qualifier creates panic where diligence is warranted. Any size claim should state whether it means transferred or decoded.
Second, not all of it is avoidable. A production store needs consent management, payment integration and tracking. The comparison does not say “this shop is badly built”; it shows how much weight additions introduce — and where the leverage sits.
The three most effective levers from our practice, in this order:
- Count scripts, do not estimate them. Fifty third-party scripts are almost always the result of tools embedded more than once, not a deliberate decision. Duplicated tracking snippets are the single most common finding.
- Apply
loading="lazy"consistently below the fold. On the measured store 29 of 42 images are lazy — the remaining 13 deserve a check on whether they are truly needed immediately. - Load JavaScript only where it is used. Slider libraries on pages without sliders are the classic.
If you want to measure your own shop, our Core Web Vitals test runs without signup and reports LCP, CLS and INP for any URL.
The most common Shopware SEO mistakes, ordered by damage
Drawn from this measurement and from running the store day to day:
1. Treating template changes as routine. Every change spawns an entire new generation of URLs. Those 5.36 addresses per product are the result. Decide once, then leave it.
2. Querying is_canonical = 0. Always returns zero rows, always looks reassuring. The correct form is IS NULL.
3. Forgetting the sales channel filter. An address can be alive in one channel and dead in another. Without the filter you measure foreign rows.
4. Mistaking plugin behaviour for core behaviour. Especially for canonicals and noindex on pagination. Remove the plugin and you silently change your indexing strategy.
5. Trusting a checkbox instead of the HTML. The hreflang case is the model example: options enabled, zero effect.
6. Building feeds from seo_url instead of the product inventory. Then you export that 4.9% of dead addresses to comparison portals.
7. Building redirect chains. On the measured store every redirect is a single hop — that is the target state, and it is not automatic once server-level rules join the party.
8. Setting noindex and a canonical on the same page. Contradictory signals: the canonical says “credit that page,” the noindex says “forget this one.” Google then decides for you.
Your audit: eight commands, one clear picture
Everything we measured here you can check yourself in minutes. No tooling required beyond curl.
SHOP="https://your-shop.com"
URL="$SHOP/a-product/"
# 1. Canonical present and absolute?
curl -s "$URL" | tr -d '\n' | grep -o '<link[^>]*rel="canonical"[^>]*>'
# 2. hreflang genuinely in the HTML? (empty = absent)
curl -s "$URL" | tr -d '\n' | grep -o '<link[^>]*hreflang[^>]*>'
# 3. robots directive on pagination
curl -s "$SHOP/a-category/?p=2" | tr -d '\n' \
| grep -o '<meta[^>]*name="robots"[^>]*>'
# 4. Does an old URL redirect, and in how many hops?
curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' "$SHOP/old-url/"
# 5. Sitemap reachable and populated?
curl -s "$SHOP/sitemap.xml" | grep -c "<loc>"
# 6. robots.txt correct?
curl -s "$SHOP/robots.txt"
# 7. Transferred versus decoded size
curl -s -o /dev/null -w 'transfer: %{size_download} B\n' \
-H 'Accept-Encoding: br,gzip' "$URL"
curl -s --compressed "$URL" | wc -c
# 8. How many scripts does the page load?
curl -s --compressed "$URL" | grep -o '<script[^>]*src="' | wc -l
The tr -d '\n' in the first three commands matters — that is exactly where our own check failed initially.
For the title work that almost always comes next, we also built a meta title generator.
Verdict: Shopware’s SEO foundation is better than its reputation
After this measurement our judgement is more nuanced than we expected going in.
What Shopware gets right out of the box: redirect logic works reliably (25 of 25 with a clean single hop). The sitemap is disciplined and contains no dead products (0 of 38). Structured data is richer than in many competing systems. Canonical handling of filter parameters is correct, and the core’s pagination logic actually matches current Google guidance.
Where it genuinely breaks: not in the framework, but in what grows around it. A disabled hreflang switch masked by two identically named plugin options. An SEO plugin silently overriding the core’s indexing strategy. A 4.9% legacy rate in the URL table. And an HTML document inflated to 3.2× its stock size by additions.
The three sentences we take away:
- A checkbox in the admin is an intention, not a result. Only the shipped HTML counts.
- An empty query looks exactly like a reassuring answer.
is_canonical = 0returns zero rows forever. - Check whether behaviour comes from the core or from a plugin — otherwise you optimise against an assumption.
If you want to go deeper: our Shopware installation deep dive measures the same store from the other end, and Shopware Cloud covers what you can influence at all in the hosted variant. Still choosing a system? Shopify vs Shopware and Shopware 6 pricing and costs cover the commercial side. For search engines beyond Google, see Bing SEO and Google AI SEO; to pick the right analysis tool, read Sistrix vs Ahrefs.
Frequently asked questions about Shopware SEO
Is Shopware good for SEO?
Yes, the foundation is solid. In our measurement of a live store, redirects worked reliably (25 of 25 old URLs returned a clean 301 in exactly one hop), the sitemap contained no dead products (0 of 38 problematic URLs), and structured data included Product, Offer, AggregateRating, MerchantReturnPolicy and OfferShippingDetails. The typical problems do not originate in the core but in plugins, themes and years of accumulated data legacy.
What are SEO URLs in Shopware and where do I configure them?
SEO URLs are your shop’s human-readable addresses, stored in the seo_url table. You control their structure under Settings → SEO using Twig templates — by default {{ product.translated.name|lower }}/{{ product.productNumber }} for products and {% for part in category.seoBreadcrumb %}{{ part|lower }}/{% endfor %} for categories. You do not need to handle umlauts or spaces: Shopware automatically wraps every template in a slugify escaper.
Does Shopware redirect old URLs automatically?
Yes, provided core.seo.redirectToCanonicalUrl is active. The deciding column is is_deleted: with is_deleted = 0 the old address returns a 301 and forwards to the current URL, with is_deleted = 1 it returns 404. We tested both — 25 of 25 non-deleted URLs returned a 301 in a single hop, and 12 of 12 deleted ones returned 404.
Why does is_canonical = 0 find no redirecting URLs?
Because Shopware never writes 0 into that column. Its source (SeoUrlPersister.php) contains $insert['is_canonical'] = (...) ? 1 : null; — the value is either 1 or NULL. Since NULL = 0 in SQL means “unknown” rather than “false,” the query always returns zero rows without raising an error. The correct predicate is WHERE is_canonical IS NULL. On our store that was the difference between “0” and the actual 6,350 rows.
Does Shopware set noindex on pagination automatically?
No. For category pages with ?p=2 the Shopware core sets neither noindex nor a canonical pointing at page 1 — on the contrary, it appends the page number to the canonical ($canonical .= '?p=' . ... in NavigationPageLoader.php), so each page self-references and stays indexable. We confirmed this on a near-stock installation: ?p=2 there returns canonical=/CMS/Image/?p=2 and robots=index,follow. A noindex on pagination virtually always comes from an SEO plugin.
How do I check whether hreflang really works in my Shopware store?
Only through the shipped HTML, never through the admin. Request the page with curl, strip the line breaks with tr and search the result for alternate link tags carrying an hreflang attribute — the audit section above contains the exact command. That shows you the tags that actually exist. On our store this returned 0 tags even though several hreflang options were enabled in the admin — those belonged to a sitemap plugin. The decisive switch is hreflang_active on the sales channel, which was 0 on all ten channels; HreflangLoader.php returns an empty collection immediately in that case.
How do I find SEO URLs pointing at deleted products?
Use a LEFT JOIN from seo_url to product with the condition p.id IS NULL, filtered on is_canonical = 1, is_deleted = 0 and your sales_channel_id. The version_id filter on the product table is mandatory, otherwise you double-count version snapshots. On our store this surfaced 38 of 775 canonical product URLs (4.9%) pointing at nothing. Important context: none of them appeared in the sitemap — it is a database problem, not an indexing problem.
Why do my Shopware URLs contain uppercase letters?
Usually for one of two reasons. Either they predate the |lower filter in your template — on our store 2,450 of 4,775 URLs from 2021 had uppercase characters, versus zero in 2025. Or the filter is missing in one spot: if |lower sits only behind the product name, the product number stays uppercase (.../SW10481). Since web servers treat paths case-sensitively, /Product/ and /product/ are two distinct addresses.
How many old URLs per product are normal?
It depends on how often you have changed templates and product names. On our store, grown since 2021, 2,604 products carry 13,964 URL rows — an average of 5.36 addresses per product, with an extreme of 32. That is not a defect but the intended redirect history. It only becomes a problem when changes become routine, because each one spawns a complete new generation of URLs.
Does Shopware make my shop slow?
The core does not. In our comparison a stock Shopware page shipped 193,578 bytes of HTML with 26 script files, while the production shop page shipped 621,290 bytes with 50 script files, 7 stylesheets and 74 inline script blocks — roughly 3.2×. The growth comes from theme, tracking, consent and plugins, not from the framework. For context, compressed transfer was only 83,858 bytes.
Do I need an SEO plugin for Shopware?
Not necessarily — but it changes more than most people realise. Canonical handling of filter parameters, redirects, sitemap and structured data all ship with Shopware itself. A plugin mainly adds convenience and extra control, such as noindex on pagination or freely assignable canonicals. What matters is knowing that it overrides the core’s default decisions: remove the plugin later and you silently change your indexing strategy.
