Reconciler SEO URLs

Stale SEO URLs remain non-canonical after a template change

A merchant opens Settings, SEO, and edits the URL template for products or categories, expecting every page to pick up the new pattern right away. Some pages do. Many do not. Weeks later the old slugs are still resolving, some as quiet redirects and some as the only URL the page has, and nobody can tell from the storefront alone which pages actually caught up and which are just sitting on outdated links. Here is why Shopware leaves this half-finished and a small script that finds the stale rows and safely triggers the indexer to fix them.

Python and Node.js Shopware Admin API Safe by default (dry run)
The short answer

Shopware 6 generates SEO URLs from a template only through the DAL indexer, never reactively the moment you save a new template in Settings, SEO. When the indexer runs, it writes a fresh seo_url row with isCanonical set to true and deliberately keeps the old row so links search engines already indexed keep resolving, now as a 301 redirect target. If the indexer never runs again for an entity, because only the admin worker is active and the message queue never fires dal:refresh:index-style indexing, that entity is stuck on the old pattern with an ambiguous or missing canonical row. Run a small Python or Node.js script that compares each seo-url-template's updatedAt against the seo_url rows for its routeName, flags any foreignKey with no canonical row, a canonical row older than the template, or a canonical seoPathInfo that does not match the current template's pattern, and then, only when you say so, calls POST /api/_action/index to let Shopware's own indexer regenerate them. Full code, tests, and sources are below.

The problem in plain words

An SEO URL is not a live computation. Shopware does not build /product-name-123 on the fly every time someone requests a product page. It stores the result in the seo_url table ahead of time, one row per entity per route per sales channel, and the storefront just looks the slug up. That stored row is what lets Shopware serve fast, human-readable URLs and issue 301 redirects for the ones that used to be canonical.

The template you edit in Settings, SEO is just the recipe, a Twig-like string such as {{ product.name }} or {{ product.translated.name }}-{{ product.productNumber }}. Saving that recipe does not rebake anything. The recipe only gets applied when Shopware's DAL indexer actually walks the affected entities and writes new rows. That indexing work rides on the message queue and the scheduled task system, not on the SEO settings screen. If a store only runs the admin worker, or the queue backs up, or the scheduled task for reindexing SEO URLs never fires, the new template sits there unused for entities nobody happens to re-save by hand.

Merchant saves new SEO URL template seo-url-template row template string updated Existing seo_url rows untouched, not regenerated only reindexing applies it, and only if the queue runs it Message queue idle admin worker only, indexer never fires Storefront serves the old slug ambiguous or missing canonical, indefinitely no re-index run means no fresh canonical row is ever created
Saving the template only updates the recipe. Existing seo_url rows stay exactly as they are until the DAL indexer actually runs against those entities.

Why it happens

This is not a bug so much as a deliberate design that has a sharp edge when the background jobs stop running. A few things combine to make it worse:

Merchants who spot this usually notice it because Google Search Console keeps showing the old URL pattern months after a redesign, or because two different slugs for the same product both return 200 instead of one 301ing to the other. The Shopware community forum has the same complaint under a title that translates to changes to SEO URL templates do not work, which is really this exact indexer gap. See the citations at the end for the exact thread and docs.

The key insight

The template is just configuration. It does not become real URLs until an index run walks the entities and writes rows. That means the fix is never to hand-edit isCanonical on a seo_url row, since canonical flags are indexer-managed and a manual edit can create two canonicals or break a redirect chain. The correct repair is to make Shopware's own indexer run again for the affected entities, the same process that would have happened automatically if the message queue had been consuming.

The fix, as a flow

We do not touch any seo_url row directly. The script reads the current template per routeName, pulls every seo_url row for that route and sales channel, groups them by foreignKey, and flags any group whose canonical is missing, older than the template, or shaped like the old pattern. Only when you turn off dry run does it call Shopware's own reindex action, then it re-checks the same groups to confirm the canonical flags now line up.

Read seo-url-template pattern + updatedAt Search seo_url rows by routeName, group by foreignKey Canonical stale or missing? no, healthy yes DRY_RUN true? log the stale foreignKey list POST /api/_action/index Shopware's own indexer runs Re-check the same groups to confirm
Detection only reads. A repair is only ever Shopware's own index action, and the script confirms the fix by re-running the same detection query.

Build it step by step

1

Get Admin API credentials

Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   # start safe, change to false to trigger reindexing
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true"   // start safe, change to false to trigger reindexing
2

Authenticate and talk to the Admin API

A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper to read templates, search seo_url, and later trigger the index action.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Read the current template, then list the seo_url rows for its route

Search seo-url-template filtered by routeName to get the current template string and its updatedAt. Then search seo_url filtered by that same routeName and salesChannelId, sorted by foreignKey then createdAt, so rows for the same entity sit next to each other in the result.

step3.py
def find_template(token, route_name):
    body = {
        "filter": [{"type": "equals", "field": "routeName", "value": route_name}],
        "total-count-mode": 1,
    }
    data = api("POST", "/api/search/seo-url-template", token, body)
    rows = data.get("data", [])
    return rows[0] if rows else None


def find_seo_urls(token, route_name, sales_channel_id, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "routeName", "value": route_name},
            {"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
        ],
        "sort": [
            {"field": "foreignKey", "order": "ASC"},
            {"field": "createdAt", "order": "ASC"},
        ],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/seo-url", token, body)
step3.js
async function findTemplate(token, routeName) {
  const body = {
    filter: [{ type: "equals", field: "routeName", value: routeName }],
    "total-count-mode": 1,
  };
  const data = await api("POST", "/api/search/seo-url-template", token, body);
  const rows = data.data || [];
  return rows.length ? rows[0] : null;
}

async function findSeoUrls(token, routeName, salesChannelId, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "routeName", value: routeName },
      { type: "equals", field: "salesChannelId", value: salesChannelId },
    ],
    sort: [
      { field: "foreignKey", order: "ASC" },
      { field: "createdAt", order: "ASC" },
    ],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/seo-url", token, body);
}
4

Decide, with one pure function

Group the rows by foreignKey, drop isDeleted rows, and check three things in order: is there any canonical row at all, does the canonical row's isModified being false and its updatedAt predating the template mean the indexer never touched it, and does the canonical seoPathInfo actually match the pattern the current template implies. A healthy group returns nothing.

decide.py
def is_seo_url_stale(seo_url_rows, template_updated_at, current_template_pattern):
    groups = {}
    for row in seo_url_rows:
        if row.get("isDeleted"):
            continue
        key = (row["foreignKey"], row["routeName"])
        groups.setdefault(key, []).append(row)

    flagged = []
    for (foreign_key, route_name), rows in groups.items():
        canonical = next((r for r in rows if r.get("isCanonical") is True), None)

        if canonical is None:
            flagged.append({"foreignKey": foreign_key, "reason": "no_canonical"})
            continue

        if canonical.get("isModified") is False and canonical["updatedAt"] < template_updated_at:
            flagged.append({"foreignKey": foreign_key, "reason": "canonical_predates_template"})
            continue

        if not current_template_pattern.search(canonical["seoPathInfo"]):
            flagged.append({"foreignKey": foreign_key, "reason": "canonical_pattern_mismatch"})

    return flagged
decide.js
export function isSeoUrlStale(seoUrlRows, templateUpdatedAt, currentTemplatePattern) {
  const groups = new Map();
  for (const row of seoUrlRows) {
    if (row.isDeleted) continue;
    const key = `${row.foreignKey}::${row.routeName}`;
    if (!groups.has(key)) groups.set(key, { foreignKey: row.foreignKey, rows: [] });
    groups.get(key).rows.push(row);
  }

  const flagged = [];
  for (const { foreignKey, rows } of groups.values()) {
    const canonical = rows.find((r) => r.isCanonical === true);

    if (!canonical) {
      flagged.push({ foreignKey, reason: "no_canonical" });
      continue;
    }

    if (canonical.isModified === false && canonical.updatedAt < templateUpdatedAt) {
      flagged.push({ foreignKey, reason: "canonical_predates_template" });
      continue;
    }

    if (!currentTemplatePattern.test(canonical.seoPathInfo)) {
      flagged.push({ foreignKey, reason: "canonical_pattern_mismatch" });
    }
  }

  return flagged;
}
5

Repair by triggering Shopware's own indexer, never by hand-editing isCanonical

Canonical seo_url rows are indexer-managed. Patching isCanonical yourself can leave two canonicals for the same entity and route, or snap a redirect chain that search engines depend on. The supported repair is POST /api/_action/index, which re-runs Shopware's registered indexers including the SEO URL indexer. Call GET /api/_action/index first to see the exact indexer identifiers registered on that store if you want to scope the run with {"only": [...]} instead of a full reindex.

repair.py
def list_indexers(token):
    return api("GET", "/api/_action/index", token)


def trigger_reindex(token, only=None):
    body = {"only": only} if only else None
    return api("POST", "/api/_action/index", token, body)
repair.js
async function listIndexers(token) {
  return api("GET", "/api/_action/index", token);
}

async function triggerReindex(token, only) {
  const body = only ? { only } : undefined;
  return api("POST", "/api/_action/index", token, body);
}
6

Wire it together behind a DRY_RUN guard, and confirm afterward

In dry run, the script only logs which foreignKey and routeName pairs are stale and why, plus the template's pattern, and touches nothing. With DRY_RUN=false it calls POST /api/_action/index, then re-runs the exact same detection query against the same routes to confirm the canonical flags now match the current template before the batch is marked resolved.

Run it safe

Always start with DRY_RUN=true and read the flagged list before triggering anything. Never PATCH isCanonical on a seo_url row by hand. The only write this script ever makes is POST /api/_action/index, which is the same reindex Shopware's own scheduled task would have run if the message queue had been consuming.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever writes by calling Shopware's own index action, never by patching a seo_url row directly.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

reindex_stale_seo_urls.py
"""Find Shopware 6 SEO URLs left stale after a template change, and repair them safely.

Shopware only regenerates seo_url rows from a template through the DAL indexer, never
reactively when the template is saved in Settings, SEO. Shopware also deliberately keeps
the old row so links search engines already indexed keep resolving, now as a redirect
target instead of the canonical URL. If the indexer never runs again for an entity, the
old row stays as an ambiguous or missing canonical indefinitely. This script never
hand-edits isCanonical; the only write it makes is Shopware's own POST /api/_action/index,
the same reindex the message queue would normally trigger. Run on a schedule or on demand.
Safe to run again and again.
"""
import os
import re
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reindex_stale_seo_urls")

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ROUTE_NAMES = os.environ.get("ROUTE_NAMES", "frontend.detail.page,frontend.navigation.page").split(",")
SALES_CHANNEL_ID = os.environ.get("SALES_CHANNEL_ID", "")


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api(method, path, token, json_body=None):
    r = requests.request(
        method,
        f"{SHOPWARE_URL}{path}",
        json=json_body,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def find_template(token, route_name):
    body = {
        "filter": [{"type": "equals", "field": "routeName", "value": route_name}],
        "total-count-mode": 1,
    }
    data = api("POST", "/api/search/seo-url-template", token, body)
    rows = data.get("data", [])
    return rows[0] if rows else None


def find_seo_urls(token, route_name, sales_channel_id, page=1, limit=500):
    body = {
        "page": page,
        "limit": limit,
        "filter": [
            {"type": "equals", "field": "routeName", "value": route_name},
            {"type": "equals", "field": "salesChannelId", "value": sales_channel_id},
        ],
        "sort": [
            {"field": "foreignKey", "order": "ASC"},
            {"field": "createdAt", "order": "ASC"},
        ],
        "total-count-mode": 1,
    }
    return api("POST", "/api/search/seo-url", token, body)


def is_seo_url_stale(seo_url_rows, template_updated_at, current_template_pattern):
    groups = {}
    for row in seo_url_rows:
        if row.get("isDeleted"):
            continue
        key = (row["foreignKey"], row["routeName"])
        groups.setdefault(key, []).append(row)

    flagged = []
    for (foreign_key, route_name), rows in groups.items():
        canonical = next((r for r in rows if r.get("isCanonical") is True), None)

        if canonical is None:
            flagged.append({"foreignKey": foreign_key, "reason": "no_canonical"})
            continue

        if canonical.get("isModified") is False and canonical["updatedAt"] < template_updated_at:
            flagged.append({"foreignKey": foreign_key, "reason": "canonical_predates_template"})
            continue

        if not current_template_pattern.search(canonical["seoPathInfo"]):
            flagged.append({"foreignKey": foreign_key, "reason": "canonical_pattern_mismatch"})

    return flagged


def template_to_pattern(template_string):
    """Loosely turn a template's placeholders into a regex, used only to sanity-check shape."""
    escaped = re.escape(template_string)
    placeholder = re.escape("{{") + r".*?" + re.escape("}}")
    escaped = re.sub(placeholder.replace("\\{\\{.*?\\}\\}", r"\{\{.*?\}\}"), ".+", template_string)
    return re.compile(re.sub(r"\{\{\s*[\w\.]+\s*\}\}", ".+", escaped))


def list_indexers(token):
    return api("GET", "/api/_action/index", token)


def trigger_reindex(token, only=None):
    body = {"only": only} if only else None
    return api("POST", "/api/_action/index", token, body)


def run():
    token = get_token()
    total_flagged = 0

    for route_name in ROUTE_NAMES:
        route_name = route_name.strip()
        template = find_template(token, route_name)
        if template is None:
            log.info("No seo-url-template found for routeName '%s', skipping.", route_name)
            continue

        pattern = template_to_pattern(template["template"])
        seo_urls = find_seo_urls(token, route_name, SALES_CHANNEL_ID).get("data", [])
        flagged = is_seo_url_stale(seo_urls, template["updatedAt"], pattern)

        for item in flagged:
            log.warning(
                "routeName=%s foreignKey=%s reason=%s. %s",
                route_name, item["foreignKey"], item["reason"],
                "dry run, no write issued" if DRY_RUN else "will trigger reindex",
            )
        total_flagged += len(flagged)

    if total_flagged == 0:
        log.info("Done. No stale SEO URLs found across %d route(s).", len(ROUTE_NAMES))
        return

    if DRY_RUN:
        log.info("Done. %d stale SEO URL group(s) found. Dry run, no reindex triggered.", total_flagged)
        return

    log.info("Triggering POST /api/_action/index to repair %d stale group(s)...", total_flagged)
    trigger_reindex(token)

    # Re-check the same routes to confirm the indexer actually fixed the canonical flags.
    still_stale = 0
    for route_name in ROUTE_NAMES:
        route_name = route_name.strip()
        template = find_template(token, route_name)
        if template is None:
            continue
        pattern = template_to_pattern(template["template"])
        seo_urls = find_seo_urls(token, route_name, SALES_CHANNEL_ID).get("data", [])
        still_stale += len(is_seo_url_stale(seo_urls, template["updatedAt"], pattern))

    if still_stale:
        log.warning("Reindex ran, but %d group(s) are still stale. They may need a follow-up run.", still_stale)
    else:
        log.info("Reindex confirmed. All previously stale SEO URL groups now have a current canonical.")


if __name__ == "__main__":
    run()
reindex-stale-seo-urls.js
/**
 * Find Shopware 6 SEO URLs left stale after a template change, and repair them safely.
 *
 * Shopware only regenerates seo_url rows from a template through the DAL indexer, never
 * reactively when the template is saved in Settings, SEO. Shopware also deliberately keeps
 * the old row so links search engines already indexed keep resolving, now as a redirect
 * target instead of the canonical URL. If the indexer never runs again for an entity, the
 * old row stays as an ambiguous or missing canonical indefinitely. This script never
 * hand-edits isCanonical; the only write it makes is Shopware's own POST /api/_action/index,
 * the same reindex the message queue would normally trigger. Run on a schedule or on demand.
 * Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopware/seo-url-stale-after-template-change/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ROUTE_NAMES = (process.env.ROUTE_NAMES || "frontend.detail.page,frontend.navigation.page").split(",");
const SALES_CHANNEL_ID = process.env.SALES_CHANNEL_ID || "";

export function isSeoUrlStale(seoUrlRows, templateUpdatedAt, currentTemplatePattern) {
  const groups = new Map();
  for (const row of seoUrlRows) {
    if (row.isDeleted) continue;
    const key = `${row.foreignKey}::${row.routeName}`;
    if (!groups.has(key)) groups.set(key, { foreignKey: row.foreignKey, rows: [] });
    groups.get(key).rows.push(row);
  }

  const flagged = [];
  for (const { foreignKey, rows } of groups.values()) {
    const canonical = rows.find((r) => r.isCanonical === true);

    if (!canonical) {
      flagged.push({ foreignKey, reason: "no_canonical" });
      continue;
    }

    if (canonical.isModified === false && canonical.updatedAt < templateUpdatedAt) {
      flagged.push({ foreignKey, reason: "canonical_predates_template" });
      continue;
    }

    if (!currentTemplatePattern.test(canonical.seoPathInfo)) {
      flagged.push({ foreignKey, reason: "canonical_pattern_mismatch" });
    }
  }

  return flagged;
}

export function templateToPattern(templateString) {
  const escaped = templateString.replace(/\{\{\s*[\w.]+\s*\}\}/g, ".+");
  return new RegExp(escaped);
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function api(method, path, token, jsonBody) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: jsonBody ? JSON.stringify(jsonBody) : undefined,
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function findTemplate(token, routeName) {
  const body = {
    filter: [{ type: "equals", field: "routeName", value: routeName }],
    "total-count-mode": 1,
  };
  const data = await api("POST", "/api/search/seo-url-template", token, body);
  const rows = data.data || [];
  return rows.length ? rows[0] : null;
}

async function findSeoUrls(token, routeName, salesChannelId, page = 1, limit = 500) {
  const body = {
    page,
    limit,
    filter: [
      { type: "equals", field: "routeName", value: routeName },
      { type: "equals", field: "salesChannelId", value: salesChannelId },
    ],
    sort: [
      { field: "foreignKey", order: "ASC" },
      { field: "createdAt", order: "ASC" },
    ],
    "total-count-mode": 1,
  };
  return api("POST", "/api/search/seo-url", token, body);
}

async function listIndexers(token) {
  return api("GET", "/api/_action/index", token);
}

async function triggerReindex(token, only) {
  const body = only ? { only } : undefined;
  return api("POST", "/api/_action/index", token, body);
}

export async function run() {
  const token = await getToken();
  let totalFlagged = 0;

  for (const rawRouteName of ROUTE_NAMES) {
    const routeName = rawRouteName.trim();
    const template = await findTemplate(token, routeName);
    if (!template) {
      console.log(`No seo-url-template found for routeName '${routeName}', skipping.`);
      continue;
    }

    const pattern = templateToPattern(template.template);
    const data = await findSeoUrls(token, routeName, SALES_CHANNEL_ID);
    const flagged = isSeoUrlStale(data.data || [], template.updatedAt, pattern);

    for (const item of flagged) {
      console.warn(
        `routeName=${routeName} foreignKey=${item.foreignKey} reason=${item.reason}. ${DRY_RUN ? "dry run, no write issued" : "will trigger reindex"}`
      );
    }
    totalFlagged += flagged.length;
  }

  if (totalFlagged === 0) {
    console.log(`Done. No stale SEO URLs found across ${ROUTE_NAMES.length} route(s).`);
    return;
  }

  if (DRY_RUN) {
    console.log(`Done. ${totalFlagged} stale SEO URL group(s) found. Dry run, no reindex triggered.`);
    return;
  }

  console.log(`Triggering POST /api/_action/index to repair ${totalFlagged} stale group(s)...`);
  await triggerReindex(token);

  let stillStale = 0;
  for (const rawRouteName of ROUTE_NAMES) {
    const routeName = rawRouteName.trim();
    const template = await findTemplate(token, routeName);
    if (!template) continue;
    const pattern = templateToPattern(template.template);
    const data = await findSeoUrls(token, routeName, SALES_CHANNEL_ID);
    stillStale += isSeoUrlStale(data.data || [], template.updatedAt, pattern).length;
  }

  if (stillStale) {
    console.warn(`Reindex ran, but ${stillStale} group(s) are still stale. They may need a follow-up run.`);
  } else {
    console.log("Reindex confirmed. All previously stale SEO URL groups now have a current canonical.");
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a foreignKey ends up in the stale list a human or a reindex acts on. Because is_seo_url_stale is pure, no network and no Shopware instance is needed. Fixture arrays with fixed timestamps cover no canonical at all, a canonical older than the template, a canonical whose path does not match the pattern, and a healthy group that should not be flagged.

test_seo_url_stale.py
import re
from reindex_stale_seo_urls import is_seo_url_stale

TEMPLATE_UPDATED_AT = "2026-07-05T00:00:00.000+00:00"
CURRENT_PATTERN = re.compile(r".+-\d+")  # e.g. product-name-12345


def row(**over):
    base = {
        "foreignKey": "prod-1",
        "routeName": "frontend.detail.page",
        "seoPathInfo": "product-name-12345",
        "isCanonical": True,
        "isDeleted": False,
        "isModified": True,
        "updatedAt": "2026-07-06T00:00:00.000+00:00",
    }
    base.update(over)
    return base


def test_healthy_group_is_not_flagged():
    rows = [row()]
    assert is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN) == []


def test_flags_no_canonical():
    rows = [row(isCanonical=False), row(isCanonical=None, foreignKey="prod-1")]
    result = is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN)
    assert result == [{"foreignKey": "prod-1", "reason": "no_canonical"}]


def test_flags_canonical_predating_template_when_not_modified():
    rows = [row(isModified=False, updatedAt="2026-07-01T00:00:00.000+00:00")]
    result = is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN)
    assert result == [{"foreignKey": "prod-1", "reason": "canonical_predates_template"}]


def test_does_not_flag_predates_template_when_isModified_true():
    rows = [row(isModified=True, updatedAt="2026-07-01T00:00:00.000+00:00")]
    assert is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN) == []


def test_flags_pattern_mismatch():
    rows = [row(seoPathInfo="product-name")]  # no trailing -number
    result = is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN)
    assert result == [{"foreignKey": "prod-1", "reason": "canonical_pattern_mismatch"}]


def test_excludes_deleted_rows_from_grouping():
    rows = [row(isDeleted=True, isCanonical=False)]
    assert is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN) == []


def test_multiple_foreign_keys_only_flags_the_stale_one():
    rows = [
        row(foreignKey="prod-1"),
        row(foreignKey="prod-2", isCanonical=False),
    ]
    result = is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN)
    assert result == [{"foreignKey": "prod-2", "reason": "no_canonical"}]


def test_old_noncanonical_redirect_row_does_not_trigger_a_flag_on_its_own():
    rows = [
        row(isCanonical=True, seoPathInfo="product-name-12345"),
        row(isCanonical=False, seoPathInfo="product-name", updatedAt="2026-06-01T00:00:00.000+00:00"),
    ]
    assert is_seo_url_stale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN) == []
seo-url-stale.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isSeoUrlStale } from "./reindex-stale-seo-urls.js";

const TEMPLATE_UPDATED_AT = "2026-07-05T00:00:00.000+00:00";
const CURRENT_PATTERN = /.+-\d+/; // e.g. product-name-12345

const row = (over = {}) => ({
  foreignKey: "prod-1",
  routeName: "frontend.detail.page",
  seoPathInfo: "product-name-12345",
  isCanonical: true,
  isDeleted: false,
  isModified: true,
  updatedAt: "2026-07-06T00:00:00.000+00:00",
  ...over,
});

test("healthy group is not flagged", () => {
  const rows = [row()];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), []);
});

test("flags no canonical", () => {
  const rows = [row({ isCanonical: false }), row({ isCanonical: null, foreignKey: "prod-1" })];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), [
    { foreignKey: "prod-1", reason: "no_canonical" },
  ]);
});

test("flags canonical predating template when not modified", () => {
  const rows = [row({ isModified: false, updatedAt: "2026-07-01T00:00:00.000+00:00" })];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), [
    { foreignKey: "prod-1", reason: "canonical_predates_template" },
  ]);
});

test("does not flag predates template when isModified true", () => {
  const rows = [row({ isModified: true, updatedAt: "2026-07-01T00:00:00.000+00:00" })];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), []);
});

test("flags pattern mismatch", () => {
  const rows = [row({ seoPathInfo: "product-name" })];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), [
    { foreignKey: "prod-1", reason: "canonical_pattern_mismatch" },
  ]);
});

test("excludes deleted rows from grouping", () => {
  const rows = [row({ isDeleted: true, isCanonical: false })];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), []);
});

test("multiple foreign keys only flags the stale one", () => {
  const rows = [
    row({ foreignKey: "prod-1" }),
    row({ foreignKey: "prod-2", isCanonical: false }),
  ];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), [
    { foreignKey: "prod-2", reason: "no_canonical" },
  ]);
});

test("old non-canonical redirect row does not trigger a flag on its own", () => {
  const rows = [
    row({ isCanonical: true, seoPathInfo: "product-name-12345" }),
    row({ isCanonical: false, seoPathInfo: "product-name", updatedAt: "2026-06-01T00:00:00.000+00:00" }),
  ];
  assert.deepEqual(isSeoUrlStale(rows, TEMPLATE_UPDATED_AT, CURRENT_PATTERN), []);
});

Case studies

Redesign migration

The agency changed the product URL template and stopped there

An agency migrated a client's Shopware store to a new theme and, as part of the polish, updated the product SEO URL template to include the product number for uniqueness. They saved the new template in the admin, checked a couple of products, saw the new pattern, and called it done.

Three months later Search Console still showed the old pattern for most of the catalog. The message queue was only ever consumed by the request-triggered admin worker, so the scheduled reindex had been queuing up without running. Detection found hundreds of products with a canonical row that predated the template's updatedAt, and a single POST /api/_action/index run, confirmed by re-running detection, brought every one of them onto the new pattern.

Category restructure

Some categories never had a canonical row at all

A merchant reorganized their category tree and updated the navigation URL template at the same time. Most categories picked up the new slugs fine, but a batch of categories that had been created through an import script, and never touched by a normal save, kept serving an old link with no canonical marked true at all after a partial reindex.

The detection script flagged those categories under no_canonical specifically, distinct from the ones that just had a stale pattern. That distinction mattered, since it pointed the team at an import path that was skipping indexing entirely, not just a queue backlog, and let them fix the root cause instead of only patching the symptom.

What good looks like

After this runs, every route's seo_url rows have exactly one canonical that matches the current template, and old canonicals live on only as intentional 301 redirect targets, not as ambiguous duplicates. No row was ever hand-patched, so the redirect chain search engines rely on stays intact. Run it after every template change and on a schedule, since the real fix, keeping the message queue consumed, is what prevents this from recurring, and this script is the safety net for the times it does not.

FAQ

Why do old SEO URLs still work after I changed the SEO URL template in Shopware 6?

Shopware keeps the old seo_url row on purpose so links that search engines already indexed keep resolving, now as a 301 redirect target instead of the canonical URL. The new template only takes effect for a product or category once the DAL indexer actually runs against it, which happens through the message queue and scheduled tasks, not the moment you save the template in Settings > SEO.

Is it safe to edit isCanonical directly on a seo_url row with the Admin API?

No. Canonical seo_url rows are indexer-managed, and hand-editing isCanonical can leave two rows marked canonical for the same entity and route, or break the 301 redirect chain search engines rely on. The safe repair is to trigger Shopware's own indexing process with POST /api/_action/index and let the SEO URL indexer recompute the canonical flags consistently.

How do I know if a stale SEO URL is actually a problem worth fixing?

Flag it when a foreignKey and routeName group has no row with isCanonical true, when the canonical row predates the seo-url-template's updatedAt and isModified is false, or when the canonical seoPathInfo does not match the pattern implied by the current template. Those three signs mean the indexer never regenerated that entity's SEO URL after the template changed.

Related field notes

Citations

On the problem:

  1. Shopware Community Forum: Anderungen der SEO Url Templates funktionieren nicht. forum.shopware.com aenderungen-der-seo-url-templates-funktionieren-nicht
  2. p16r: Shopware 6, how do SEO URLs work. p16r.nl 2021-08-01-shopware-6-how-do-seo-urls-work
  3. BrocksiNet: Deep dive into Shopware SEO URLs, how they work, how to configure them, and how to avoid growth issues. brocksi.net blog seo-urls-deep-dive-shopware-6

On the solution:

  1. Shopware 6 Documentation: Settings, SEO settings. docs.shopware.com shopware-6-en settings seo
  2. Shopware 6 Documentation: Configuration, Caches and Indexes. docs.shopware.com shopware-6-en configuration caches-indexes
  3. Shopware Platform Documentation: SEO URLs for developers. docs.shopware.com shopware-platform-dev-en internals storefront seo-urls-for-developers

Stuck on a tricky one?

If you have a problem in Shopware SEO URLs, order states, stock, or data integrity that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clean up your canonicals?

If this saved you from chasing duplicate or missing SEO URLs across a big catalog, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopware field notes