Magento 2 Hreflang: Multilanguage SEO Done Correctly
Magento 2 handles multiple languages well on the content side. Each store view has its own locale, translations, product names, URL keys and CMS content. What it does not do out of the box is tell search engines that the English, German and Portuguese versions of a page belong together. That is the job of hreflang annotations, and Magento 2.4 does not output them.
This article covers how to structure a multilanguage Magento store for search, how hreflang works, and how to generate correct alternate links for products, categories and CMS pages.
Store structure comes first
Hreflang only works if each language version has its own URL. In Magento there are three common ways to get that:
- Store code in the URL, such as
example.com/en/andexample.com/de/, enabled with Add Store Code to Urls (web/url/use_store) or with a separate base URL per store view. - Subdomains, such as
en.example.com, with a base URL per store view. - Separate domains per country, each mapped to a website or store view in the web server config.
Any of these works for hreflang. What does not work is switching language through a cookie on the same URL. Crawlers do not keep the store cookie between requests, so they only ever see the default store view. Make sure each store view is reachable by URL alone. The structure choices behind websites, stores and store views are in multi store and multi website setup.
How hreflang works
Each page lists all its language versions, including itself, as link elements in the head:
<link rel="alternate" hreflang="en" href="https://example.com/en/blue-shirt.html"/>
<link rel="alternate" hreflang="de" href="https://example.com/de/blaues-hemd.html"/>
<link rel="alternate" hreflang="pt-BR" href="https://example.com/br/camisa-azul.html"/>
<link rel="alternate" hreflang="x-default" href="https://example.com/en/blue-shirt.html"/>
The rules that matter:
- Annotations must be reciprocal. If the English page points to the German one, the German page must point back. Missing return links make search engines ignore the pair.
- Each URL must be the canonical URL of that version. Pointing hreflang at a URL that redirects or has a canonical tag elsewhere breaks the set.
- Language codes are ISO 639-1, with an optional ISO 3166-1 region, such as
pt-BR. Magento locales use an underscore (pt_BR), so they need converting. x-defaultmarks the version for users who match no listed language. Usually that is the main language store or a language selector page.
Finding the equivalent URL in each store view
For products and categories, the URL in another store view comes from its URL rewrite in that store. Products can have different URL keys per store view, so you cannot just swap the base URL. The URL finder service looks up the rewrite for an entity in a given store.
<?php
declare(strict_types=1);
namespace Vendor\Hreflang\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Api\Data\StoreInterface;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreManagerInterface;
use Magento\UrlRewrite\Model\UrlFinderInterface;
use Magento\UrlRewrite\Service\V1\Data\UrlRewrite;
class AlternateUrls
{
public function __construct(
private readonly UrlFinderInterface $urlFinder,
private readonly StoreManagerInterface $storeManager,
private readonly ScopeConfigInterface $scopeConfig
) {
}
/**
* @return array<string, string>
*/
public function forEntity(string $entityType, int $entityId): array
{
$current = $this->storeManager->getStore();
$links = [];
foreach ($this->storeManager->getStores() as $store) {
if (!$store->getIsActive() || (int) $store->getWebsiteId() !== (int) $current->getWebsiteId()) {
continue;
}
$path = $this->findRequestPath($entityType, $entityId, (int) $store->getId());
if ($path === null) {
continue;
}
$links[$this->getHreflangCode($store)] = $store->getBaseUrl() . $path;
}
return $links;
}
private function findRequestPath(string $entityType, int $entityId, int $storeId): ?string
{
$rewrites = $this->urlFinder->findAllByData([
UrlRewrite::ENTITY_TYPE => $entityType,
UrlRewrite::ENTITY_ID => $entityId,
UrlRewrite::STORE_ID => $storeId,
UrlRewrite::REDIRECT_TYPE => 0,
]);
foreach ($rewrites as $rewrite) {
if (empty($rewrite->getMetadata())) {
return $rewrite->getRequestPath();
}
}
return null;
}
private function getHreflangCode(StoreInterface $store): string
{
$locale = (string) $this->scopeConfig->getValue(
'general/locale/code',
ScopeInterface::SCOPE_STORE,
$store->getId()
);
return str_replace('_', '-', $locale);
}
}
Some notes on this code:
- Rewrites with metadata are the category path variants of product URLs. Skipping them gives the short product URL, which should also be the canonical one.
REDIRECT_TYPE => 0excludes 301 entries left behind by URL key changes.- If a product is not assigned to a website, or is disabled in one store view, there will be no usable page there. Checking only the rewrite is not enough for disabled products, because rewrites can remain. Load the product status per store or filter by a product collection before building the list.
- The example limits links to the current website. If your languages live on different websites, remove that check, but make sure the product exists in each one.
For entity types, use product, category and cms-page. The first two are defined as constants on ProductUrlRewriteGenerator and CategoryUrlRewriteGenerator. When two locales map to the same code, for example two English store views for the US and UK, use region specific locales such as en_US and en_GB so the codes stay unique.
Rendering the links
Add a block to the head.additional container in the layouts for catalog_product_view, catalog_category_view and cms_page_view. The block gets the current entity, calls the service and outputs one link per entry, plus x-default. Escape every URL with $escaper->escapeUrl().
These links are part of the cached page, and the page cache is per store view and per URL, so that is correct. When a URL key changes in one store view, the alternates on the other store views change too. For products and categories this is handled, because saving the entity cleans its cache tag on every store view. For CMS pages linked by a custom mapping, a change to one page does not clean its siblings, so add the cache tags of all pages in the group to the block or clean them in a save observer.
CMS pages are the hard part
Products and categories are the same entity in every store view, with translated values. CMS pages are not. A typical multilanguage store has one CMS page per language, each a separate entity with its own ID and identifier, such as about-us and ueber-uns. Magento has no built in link between them.
The cleanest fix is a mapping field. Add a column such as hreflang_group to cms_page through declarative schema, expose it in the CMS page form, and give translated versions the same group value. The hreflang block then finds all active pages with the same group and builds links from their identifiers and store assignments. How to add the column is covered in declarative schema and data patches.
A CMS page assigned to "All Store Views" with the same content in every language should not get hreflang at all, since it is really one page.
Hreflang in the XML sitemap
Instead of head links, you can list alternates inside the XML sitemap with xhtml:link elements per URL. It keeps the HTML lighter and is easier to generate for large catalogs in one batch. The Magento sitemap generator does not do it out of the box, so this means extending the sitemap model or generating a separate sitemap. Use one method, head or sitemap, not both, to avoid conflicting signals when one of them is out of date.
Common mistakes
- Hreflang pointing to the home page of the other language instead of the equivalent page.
- Using the store code as the language code, such as
hreflang="br", which is not a valid code for Portuguese. - Including store views that are disabled or still under construction.
- Links to product URLs with category paths when the canonical tag points to the short URL.
- Translated content that is only partly translated, with English product descriptions on the German store. Hreflang does not fix thin or duplicate content.
After release, check the international targeting reports and crawl a sample of pages to confirm reciprocity. Hreflang is one item on the wider list in the technical SEO checklist, and translation itself is covered in Magento 2 translations.
FAQ
Does Magento 2 add hreflang tags automatically?
No. Magento 2.4 does not output hreflang tags. You need a custom module or an extension that builds alternate links for products, categories and CMS pages.
Which language code should I use for a Magento store view?
Take the store view locale from general/locale/code and replace the underscore with a hyphen, so pt_BR becomes pt-BR. Make sure every store view in the set has a unique code.
How do I link translated CMS pages for hreflang?
Magento has no link between CMS pages in different languages. Add a shared group field to the CMS page and use it to find the equivalent pages.
Can I switch Magento languages with a cookie and still use hreflang?
No. Each language version needs its own URL. Use store codes in the URL, subdomains or separate domains.
I am available for freelance Magento and Adobe Commerce work, including multilanguage and multi store setups. If you need help with this, get in touch.
Related articles
Magento 2 Core Web Vitals: How to Fix LCP, INP and CLS
Where Magento 2 Luma stores lose Core Web Vitals and how to fix LCP, INP and CLS: server response, CSS, fonts, images, gallery, third party scripts.
Magento 1 to Magento 2 Migration: Data, SEO and Cutover Plan
A practical Magento 1 to 2.4.8 migration plan: Data Migration Tool settings, data and delta, URL rewrites and 301 redirects, rehearsals and a cutover checklist.
Magento 2 Multi Store and Multi Website Setup Guide
How to plan and set up Magento 2 multi website, multi store and store views: what each scope controls, Nginx routing, config per scope and keeping it in code.
Magento 2 Structured Data: Product JSON-LD the Right Way
Add product structured data to Magento 2 with JSON-LD: what Luma outputs, a view model and layout for Product and Offer, cache, configurables and testing.