Most WordPress hreflang advice is "install a multilingual plugin and it handles it." That is fine when the plugin owns your translations. It is the wrong answer in the case I hit most often: a site with two or three language versions built as separate subdirectories, separate installs, or a set of manually translated pages, where nobody wants a full translation framework and its database schema for four pages.
I maintain WordPress properties in that shape, including documentation sites where the translations are hand-written and live under /de/ and /es/. The hreflang there is forty lines in a theme file, it reads from one array, and it has never drifted. This guide is that code, plus the parts that go wrong when a plugin is emitting hreflang alongside your theme.
It sits under the complete hreflang guide, next to the Next.js version.
TL;DR: WordPress does not emit hreflang on its own. Hook
wp_head, loop over a locale map, and print the full reciprocal set includingx-defaulton every page in the group. Build URLs withhome_url()or a per-locale origin rather than hardcoding, keep each page's canonical self-referential, and check that no SEO plugin is already emitting a competing block. For multi-site setups,get_sites()gives you the map; for a single site with translated pages, a post meta field mapping counterparts is enough.
Does WordPress add hreflang by itself?
No. Core outputs a canonical link, a shortlink, and various feed and REST links from wp_head, but nothing about language alternates. get_locale() knows what language the site is in and no part of core turns that into an annotation.
Plugins fill the gap. Polylang and WPML emit hreflang for the translations they manage, and most SEO plugins will emit it if you configure a translation relationship they can see. If you are already using one of those and it covers your whole site, use it and skip this article. The reason to hand-roll is that your translations exist outside whatever the plugin knows about, and a plugin that emits a partial set is worse than one that emits none.
The minimal version: one array, one hook
For a site whose translations are a known, small set of URL prefixes:
<?php
/**
* Hreflang annotations for a hand-translated multilingual site.
* Drop in the theme's functions.php or a small mu-plugin.
*/
function mysite_hreflang_map(): array {
return [
'en-US' => 'https://example.com',
'de-DE' => 'https://example.com/de',
'es-ES' => 'https://example.com/es',
'x-default' => 'https://example.com',
];
}
function mysite_print_hreflang(): void {
$path = mysite_current_path(); // e.g. '/pricing/'
foreach ( mysite_hreflang_map() as $hreflang => $origin ) {
printf(
'<link rel="alternate" hreflang="%s" href="%s" />' . "\n",
esc_attr( $hreflang ),
esc_url( untrailingslashit( $origin ) . $path )
);
}
}
add_action( 'wp_head', 'mysite_print_hreflang', 1 );
Three things this gets right that hand-written blocks usually do not.
It prints the full set on every page, including the page's own language. Reciprocity is satisfied structurally: there is no branch that could omit an entry, because there is no branch at all.
x-default is a row in the same array, so it is emitted by the same loop and cannot be forgotten on one template.
URLs are escaped. esc_url on an href in wp_head is not optional, and hreflang values go through esc_attr for the same reason.
Working out the current path
The path is the part that needs care, because it has to be the path within the language, not the full request URI.
function mysite_current_path(): string {
$home = wp_parse_url( home_url(), PHP_URL_PATH ) ?: '';
$req = wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ) ?: '/';
// Strip the language prefix this install is served under.
if ( $home && str_starts_with( $req, $home ) ) {
$req = substr( $req, strlen( $home ) );
}
return user_trailingslashit( '/' . ltrim( $req, '/' ) );
}
If your German install lives at example.com/de and the visitor is on example.com/de/preise/, this returns /preise/, which then gets prefixed with each locale's origin. The trailing slash handling matters more than it looks: /preise and /preise/ are different URLs, and pointing an annotation at the non-canonical form is the trailing-slash mismatch that produces return-tag errors on a set that otherwise looks perfect. user_trailingslashit respects the site's permalink setting, so it matches whatever WordPress serves.
The assumption here is that translated URLs are structurally parallel: /pricing/ maps to /de/pricing/. If your German slugs are translated too, /de/preise/, this function is not enough and you need an explicit mapping, which is the next section.
When slugs differ: a post-meta mapping
Translated slugs are better for users and for ranking, and they mean the path cannot be derived. Store the counterparts instead:
/**
* Meta key holds a JSON map: {"de-DE": 123, "es-ES": 456} of post IDs,
* or absolute URLs for external installs.
*/
function mysite_alternates_for( int $post_id ): array {
$raw = get_post_meta( $post_id, '_hreflang_alternates', true );
$map = $raw ? json_decode( $raw, true ) : [];
if ( ! is_array( $map ) ) {
return [];
}
$out = [];
foreach ( $map as $hreflang => $target ) {
$url = is_numeric( $target ) ? get_permalink( (int) $target ) : $target;
if ( $url ) {
$out[ $hreflang ] = $url;
}
}
return $out;
}
Then the wp_head callback merges the current post's own entry into that map before printing, so the set is complete and self-referential:
function mysite_print_hreflang_mapped(): void {
if ( ! is_singular() ) {
return;
}
$post_id = get_queried_object_id();
$alts = mysite_alternates_for( $post_id );
if ( count( $alts ) < 1 ) {
return; // Nothing to declare: a one-entry cluster is not a cluster.
}
$alts[ mysite_hreflang_for_locale( get_locale() ) ] = get_permalink( $post_id );
$alts['x-default'] = $alts['en-US'] ?? get_permalink( $post_id );
foreach ( $alts as $hreflang => $url ) {
printf(
'<link rel="alternate" hreflang="%s" href="%s" />' . "\n",
esc_attr( $hreflang ),
esc_url( $url )
);
}
}
The maintenance cost is that the meta has to be written on both sides. An admin UI with a counterpart picker is the honest solution; a WP-CLI command that populates the map from a CSV is the fast one. What you must not do is populate it on one side only, because a one-way declaration is exactly the missing return tag failure.
Multisite: derive the map from the network
If each language is a site in a multisite network, the map already exists:
function mysite_network_hreflang_map(): array {
$map = [];
foreach ( get_sites( [ 'number' => 50 ] ) as $site ) {
switch_to_blog( (int) $site->blog_id );
$map[ mysite_hreflang_for_locale( get_locale() ) ] = home_url();
restore_current_blog();
}
return $map;
}
Cache the result in a transient; switch_to_blog in a wp_head hook on every request is a real cost on a network of any size. And be careful with get_locale() under switch_to_blog: it reflects the switched site only if the site's WPLANG option is set, which is the usual case but not guaranteed on networks configured through a constant.
Which approach fits your site?
| Setup | Approach | Effort |
|---|---|---|
| Parallel slugs, few locales | Static array plus derived path | Lowest; one function |
| Translated slugs | Post-meta counterpart map | Moderate; needs an editing workflow |
| Multisite, one site per language | Derive from get_sites(), cached |
Low code, needs cache discipline |
| Polylang or WPML managing translations | Use the plugin's output | Verify it emits x-default |
| SEO plugin with manual translation fields | Use it, then check the rendered head | Watch for duplicate blocks |
Putting hreflang in the sitemap instead
If your page heads are already crowded, or you have enough locales that the block is longer than the rest of the head, the sitemap is the alternative. WordPress 5.5 and later ship core sitemaps, and they are filterable, though the filters were not designed with alternates in mind and the namespace declaration on <urlset> is not something core lets you touch cleanly.
The pragmatic route is a standalone sitemap served from a rewrite rule rather than a fight with wp_sitemaps:
add_action( 'init', function () {
add_rewrite_rule( '^sitemap-i18n\.xml$', 'index.php?mysite_i18n_sitemap=1', 'top' );
} );
add_filter( 'query_vars', fn( $vars ) => array_merge( $vars, [ 'mysite_i18n_sitemap' ] ) );
add_action( 'template_redirect', function () {
if ( ! get_query_var( 'mysite_i18n_sitemap' ) ) {
return;
}
header( 'Content-Type: application/xml; charset=UTF-8' );
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ';
echo 'xmlns:xhtml="http://www.w3.org/1999/xhtml">' . "\n";
foreach ( mysite_translated_paths() as $path ) {
echo " <url>\n <loc>" . esc_url( home_url( $path ) ) . "</loc>\n";
foreach ( mysite_hreflang_map() as $hreflang => $origin ) {
printf(
' <xhtml:link rel="alternate" hreflang="%s" href="%s"/>' . "\n",
esc_attr( $hreflang ),
esc_url( untrailingslashit( $origin ) . $path )
);
}
echo " </url>\n";
}
echo '</urlset>';
exit;
} );
Two things to get right. The xmlns:xhtml declaration on <urlset> is required; without it the alternate entries are ignored and the file still validates as a sitemap, which is the silent-failure pattern again. And flush rewrite rules once after adding the rule, on activation rather than on every request.
Having done this, remove the head output. Emitting both is mixing implementation methods, which gives you two sources that drift.
The duplicate-block problem
This is the WordPress-specific failure and the one worth checking before you write any code. Several plugins emit hreflang, and more than one can be active at once. An SEO plugin configured with translation relationships, a multilingual plugin, and your new theme function will all happily print to wp_head, and the result is two or three blocks with different contents and possibly two x-default entries.
Google's handling of contradictory annotations within one page is to distrust them. So a site that had no hreflang and now has three blocks can be worse off than before.
Check the rendered output rather than the plugin settings:
curl -s https://example.com/de/preise/ | grep 'rel="alternate"' | grep hreflang
Count the lines and count the distinct codes. One entry per locale plus one x-default, and nothing repeated. If a plugin is emitting a set you cannot configure away, the usual escape hatch is to remove its action from wp_head rather than to add yours on top:
// Example shape: find the real hook and callback with `has_action`.
add_action( 'init', function () {
remove_action( 'wp_head', 'some_plugin_hreflang_output', 10 );
} );
Canonical tags, and not breaking them
WordPress emits rel="canonical" for singular views through rel_canonical(), and it points at the post's own permalink, which is what you want. Most SEO plugins replace it with their own, also self-referential by default.
The rule from hreflang vs canonical applies unchanged: every page canonicalizes to itself, and hreflang describes the relationships between those self-canonical pages. The WordPress-flavoured way to break it is a plugin setting that canonicalizes translated posts to a "primary" language version, which reads like a sensible deduplication option and quietly removes your translations from the index. If a setting offers to canonicalize across languages, leave it off.
Verifying the result
Fetch two sibling pages and compare their annotation blocks. They should be identical, with only the canonical differing. Then check that every URL you named returns 200 without redirecting, because retired locales and changed permalinks are how a correct set rots into a broken one.
Paste the set into the free hreflang generator to validate the codes: it checks each value against the ISO shape, flags unrecognized subtags like en-UK, catches duplicates, rejects relative URLs, and warns when the set has no fallback. It runs entirely in the browser, which matters when the URLs describe a client site that is not public yet. Then use Search Console's International Targeting report for the ongoing check, and the hreflang tool guide for the workflow around it.
Frequently asked questions
Does WordPress add hreflang tags automatically?
No. Core emits a canonical link and various head links but nothing about language alternates. Hreflang comes from a plugin or from your own wp_head output.
How do I add hreflang in WordPress without a plugin?
Hook wp_head and print a <link rel="alternate" hreflang> line for every locale in a map you control, including an x-default entry, on every page in the group. Escape the values with esc_attr and esc_url.
Where do hreflang tags go in a WordPress theme?
In a function hooked to wp_head, ideally in a small mu-plugin rather than the theme, so switching themes does not silently drop your annotations.
What if my translated pages have different slugs?
Derive nothing. Store the counterpart post IDs or URLs in post meta and read the map at render time. Populate it on both sides, or you create a missing return tag.
Do Polylang and WPML handle hreflang?
Yes, for the translations they manage. Verify the output includes x-default and that no second plugin is emitting a competing block.
Why do I have two hreflang blocks on one page?
Two things are printing to wp_head: usually an SEO plugin plus a multilingual plugin, or a plugin plus your own function. Contradictory annotations on one page are distrusted, so remove one rather than layering.
Should each WordPress language version have its own canonical?
Yes, self-referential. Do not use a plugin setting that canonicalizes translated posts to a primary language, because that removes them from the index and cancels the hreflang set.
How do I add hreflang across a WordPress multisite network?
Loop get_sites(), switch to each blog to read its locale and home_url(), and cache the resulting map in a transient. Running switch_to_blog on every request in wp_head is expensive on a large network.



