404 on product pages after moving to PrestaShop 9
The symptom
After moving to PrestaShop 9, the product pages reached through the old addresses, the ones with the category name before the ID (/category/12-product-name.html), return 404. The addresses without the category (/12-product-name.html) work, and the store browses normally. The damage comes from outside: links on other sites, bookmarks and the pages Google has already indexed still point to the old addresses, and on the next visit they find an error page instead of the product.
What it looked like and wasn’t
The first suspect, facing a 404 after a migration, is usually .htaccess: lost rewrite rules, mod_rewrite switched off. It is not that here: the request reaches PrestaShop, and the page you see is the theme’s “not found” page, generated by the store.
The second suspect is the product name: if the link_rewrite changed, the address no longer matches. But in that case PrestaShop does not answer 404. With the right ID and the wrong name it redirects with a 301 to the correct address, as long as the canonical redirect is on with the “301 Moved Permanently” option (PS_CANONICAL_REDIRECT = 2). The 404 only appears when the address has a shape that no rule recognizes.
The real cause
PrestaShop builds and recognizes addresses with one rule per page type. For products, the default rule in versions 1.7 and 8 is this (the form found in 1.7.1.2, 1.7.8.10 and 8.1):
{category:/}{id}{-:id_product_attribute}-{rewrite}{-:ean13}.html
that is, the name of the product’s default category, then the ID, the combination if any, the name and the EAN13 if any. In 9.0 the default rule, in the file classes/Dispatcher.php, became:
{id}{-:id_product_attribute}-{rewrite}.html
No category, no EAN13. We verified it by comparing the sources of 1.7.1.2, 1.7.8.10 and 8.1 (rule with the category) with those of 9.0.0 and 9.1.4 (rule without it). It is the only default rule that changes between 8.1 and 9.0, and the change is in the official 9.0 changelog (no. 37467, “SEO delete category in URL product scheme during installation”).
The default rule only applies as long as nobody has saved one. The Dispatcher loads the default rules first and then overwrites them with the ones saved in the ps_configuration table, with keys such as PS_ROUTE_product_rule and PS_ROUTE_category_rule: they are the values of the SEO & URLs page in the back office. Those keys appear when someone saves that page: in a store that has never saved it they are usually missing. In our store, which runs on 9.1, there is not a single one. A store that has never saved the page uses the default rule of the version it is running: change the version and the addresses change, without anyone having touched anything.
The PrestaShop project recognized it as a serious SEO problem. The report no. 40063 (“Legacy Category/Product URLs Return 404 With New Default Routing”) was closed on 26 November 2025 by a fix in the official upgrade module, PR no. 1535 (“Persist current product route when upgrading to 9.0.0”), which saves the route in use when there is none. The fix protects whoever upgrades with that module. It does not protect whoever sets up a fresh PrestaShop 9 installation and imports the old store’s data, which is the migration described in the report (from 1.6.1.4 to 9.0.1, with a clean install and an import): there the scheme is the new one from day one.
The test, run on 9 October 2026 on PrestaShop 9.1 with no saved rules (a test copy of our store, plus read-only requests on the store itself), with product 2 and its default category compliance-ue:
/2-eu-withdrawal-button.html: 200./compliance-ue/2-eu-withdrawal-button.html: 404./2-another-name.html(right ID, wrong name): 301 to/2-eu-withdrawal-button.html.
The fix
There are two roads. Road A brings the old scheme back and works even if the site with the new addresses is already live. Road B keeps the new, shorter scheme and redirects the old addresses.
Road A: go back to the old scheme.
- In the back office: Shop Parameters, Traffic & SEO, SEO & URLs, section “Schema of URLs”, field “Route to products”. In the code, that form field is tied to the
PS_ROUTE_product_rulekey. - Enter the rule the old store used. If it had never been changed, it is the default up to 8.x:
{category:/}{id}{-:id_product_attribute}-{rewrite}{-:ean13}.html. If it was customized, put that one back. - Save and test a product.
We tested the effect by writing that key on the test copy (not through the form, but it is the key the form writes). The old address with the category answers 200 again. The address without the category, which someone may have linked in the meantime, becomes a 301 to the one with the category, not a 404. A wrong category in the address is also corrected with a 301.
Road B: keep the new scheme and redirect the old addresses. In Apache, in the .htaccess file, before the block between PrestaShop’s “~~start~~” and “~~end~~” comments: whatever sits outside that block PrestaShop keeps when it regenerates the file (the comment itself says so). Replace the list of languages with your own store’s prefixes:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(it|en|fr|es|de|pl|nl)/[^/]+/([0-9]+(-[0-9]+)?-[^/]+\.html)$ https://%{HTTP_HOST}/$1/$2 [R=301,L]
RewriteCond %{REQUEST_URI} !^/(it|en|fr|es|de|pl|nl)/
RewriteRule ^[^/]+/([0-9]+(-[0-9]+)?-[^/]+\.html)$ https://%{HTTP_HOST}/$1 [R=301,L]
</IfModule>
The first rule rewrites addresses with a language prefix and a category, the second those of the default language, which in our store has no prefix. The condition in between avoids touching the new addresses of the form /en/12-name.html. Tested on the test copy: /compliance-ue/2-eu-withdrawal-button.html and /en/compliance-ue/2-eu-withdrawal-button.html answer 301 to the addresses without the category, while /2-eu-withdrawal-button.html, /en/2-eu-withdrawal-button.html and a CMS page stay 200. If the product name has changed in the meantime, the second hop is done by PrestaShop with the canonical redirect.
How to check that it holds, and how to avoid it
- Before touching anything, read what is saved:
SELECT name, value FROM ps_configuration WHERE name LIKE 'PS_ROUTE%';(with the table prefix of your own installation). No rows means the store uses the default rules of the installed version. - Take a sample of old product addresses from the old sitemap or from Search Console and check their response code with
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' ADDRESS. 200 or a 301 to the right page are fine. Never 404. - Also test a product with combinations, whose address contains the combination ID (
/12-3-name.html), and repeat the test with the prefix of each language. - After every change of scheme, regenerate the sitemap: it is a file written by the module at generation time, and it contains the addresses of the scheme in use that day.
- Before migrating, copy the product rule in use from the “Route to products” field and keep it in the project. If you set up a fresh installation and import the data, set and save that rule before opening the site: from then on it is a saved value and no longer depends on the version’s default.
- If you upgrade with the upgrade module, test on a copy first and compare the “Route to products” field before and after, which is also the check indicated in the fix’s PR.
If you would rather manage redirects from the back office instead of .htaccess, you can use cll_seoredirect, our module for 301/302 redirects and 404 monitoring: it accepts regular expression rules with group substitution ($1), so the rule of road B is written there in the same way. We tested it on the test copy. The problem and the fixes described here hold with any tool.
