DEV Community

Cover image for Multilingual WordPress SEO: fixing duplicate content, machine translation, and a switcher crawlers can follow
AumCreate
AumCreate

Posted on

Multilingual WordPress SEO: fixing duplicate content, machine translation, and a switcher crawlers can follow

If a translated page on your WordPress site never gets indexed, the cause is usually mechanical rather than editorial. Three things break, and each has a fix you can implement and verify in an afternoon:

  1. Duplicate content — the translations are near-identical to the original and nothing declares which URL is which language.
  2. Machine translation indexed too early — unreviewed pages go live, get crawled, and are half-translated or half-empty.
  3. The language switcher produces no crawlable URL — it swaps text with JavaScript or sets a cookie, so a crawler only ever sees one version of the site.

Below is the order I would fix them in. The PHP goes in a child theme or a small site-specific plugin; swap the meta keys for whatever your setup already writes.

Step 1: Pick a URL shape before you translate anything

URL shape Works when Watch out for
Subdirectory (/de/) One site, one host, shared authority Needs a routing layer; slugs must not collide
Subdomain (de.example.com) Separate teams or separate hosting Treated as a separate site; more setup, slower to build
Query string (?lang=de) Never for indexable content One URL, content varies by cookie — the crawler sees one version
Separate domains Separate brands or legal entities Expensive, and every domain builds authority from zero

Subdirectory is the default answer for most sites: one host, one redirect set, one sitemap, and cross-language links stay on the same domain. Changing shape later means writing a redirect map for every URL, so decide first.

The rule underneath all of this: one language = one stable URL that returns 200 and renders the content server-side. If the German version only exists after a cookie is set, you don't have a German URL, you have a German state.

Step 2: hreflang — five rules, then the code

hreflang tells a search engine "this URL is the German version of that URL". It is a hint for picking which version to show; it does not deduplicate anything. The rules people get wrong:

  1. Reciprocal. If /de/ points at /, then / must point back at /de/. One-way annotations are ignored.
  2. Self-referencing. Each page includes itself in the set.
  3. x-default. Exactly one URL in the set is marked x-default — the page to serve when no language matches.
  4. Absolute URLs, indexable pages only. Don't annotate drafts, noindexed pages, or URLs that redirect.
  5. Correct codes. de, de-AT, pt-BR, zh-Hans. Not ger, not de_DE.

Store a shared group key and a language code on each translated post, then print the set from whichever page is being viewed:

add_action( 'wp_head', function () {
    if ( ! is_singular() ) {
        return;
    }

    $post_id = get_queried_object_id();
    $group   = get_post_meta( $post_id, '_lang_group', true );
    if ( ! $group ) {
        return;
    }

    $q = new WP_Query( [
        'post_type'      => get_post_type( $post_id ),
        'post_status'    => 'publish',
        'posts_per_page' => 20,
        'meta_key'       => '_lang_group',
        'meta_value'     => $group,
        'orderby'        => 'ID',
        'order'          => 'ASC',
    ] );

    $seen        = [];
    $default_url = '';
    $printed     = 0;

    foreach ( $q->posts as $p ) {
        $code = get_post_meta( $p->ID, '_lang_code', true );

        // Skip empty codes and accidental duplicates within one group.
        if ( ! $code || isset( $seen[ $code ] ) ) {
            continue;
        }
        $seen[ $code ] = true;

        $url = get_permalink( $p );
        printf(
            '<link rel="alternate" hreflang="%s" href="%s">' . "\n",
            esc_attr( $code ),
            esc_url( $url )
        );
        $printed++;

        if ( 'en' === $code ) {
            $default_url = $url;
        }
    }

    if ( $printed && $default_url ) {
        printf(
            '<link rel="alternate" hreflang="x-default" href="%s">' . "\n",
            esc_url( $default_url )
        );
    }
}, 5 );
Enter fullscreen mode Exit fullscreen mode

Two notes. The duplicate-code guard matters — one mis-tagged post in a group and you emit two en entries, which invalidates the set. And if a translation tool already prints hreflang, don't run both; you'll get two conflicting annotations on the same page. Pick one source of truth.

Step 3: Canonical must point at the page itself

View source on the translated page and find <link rel="canonical">. If the German page's canonical points at the English URL, that page is telling the crawler "this is a duplicate, index the English one" — and that is the entire problem in one line.

WordPress core prints a self-referencing canonical for singular content, so a cross-language canonical almost always comes from a theme or plugin override. Find it:

grep -rn 'get_canonical_url\|rel="canonical"' wp-content/themes wp-content/plugins
Enter fullscreen mode Exit fullscreen mode

Remove the override for translated content. Do not "fix" duplication by pointing every translation at the original and then wonder why the translations never rank — that is the mechanism by which they never rank.

The correct combination is: hreflang declares the relationship, each URL is self-canonical, and each URL is linked from the others. hreflang alone does not consolidate anything.

Step 4: Index only translations a human has read

Machine translation is fine as a draft. The problem is the index. An unreviewed page typically has a translated title, untranslated theme strings, form labels in the wrong language, and internal links that still point at the English tree. That page gets crawled and judged.

Ship it in this order: publish → noindex until reviewed → remove noindex after a person has read the page top to bottom.

Use the wp_robots filter rather than echoing a second <meta name="robots"> tag. Two robots tags on one page is a coin flip.

add_filter( 'wp_robots', function ( array $robots ) {
    if ( ! is_singular() ) {
        return $robots;
    }

    $reviewed = get_post_meta( get_queried_object_id(), '_translation_reviewed', true );
    if ( '1' !== $reviewed ) {
        unset( $robots['index'] );
        $robots['noindex'] = true;
        $robots['follow']  = true;
    }

    return $robots;
} );
Enter fullscreen mode Exit fullscreen mode

Flip _translation_reviewed to 1 when the page is signed off.

Two details specific to machine translation:

  • Rewrite titles and meta descriptions by hand, even when the body is machine-assisted. The title is what competes in the results; a literal translation of your English title reads like a translation.
  • Decide the translated slug once and leave it. If you change it, add a 301 from the old one, or you have reset the page.

Step 5: Make the switcher a list of links

Disable JavaScript, or curl the page. If the other languages aren't in the HTML, a crawler can't see them either. A switcher that runs onchange and reloads with a cookie parameter is invisible.

The switcher should be a <nav> with real <a href> elements, and it should only list languages that exist — a link to a 404 is worse than no link.

function mytheme_language_switcher(): string {
    if ( ! is_singular() ) {
        return '';
    }

    $group = get_post_meta( get_queried_object_id(), '_lang_group', true );
    if ( ! $group ) {
        return '';
    }

    $q = new WP_Query( [
        'post_type'      => get_post_type( get_queried_object_id() ),
        'post_status'    => 'publish',
        'posts_per_page' => 20,
        'meta_key'       => '_lang_group',
        'meta_value'     => $group,
    ] );

    $links = [];
    foreach ( $q->posts as $p ) {
        $code = get_post_meta( $p->ID, '_lang_code', true );
        if ( ! $code || isset( $links[ $code ] ) ) {
            continue;
        }
        $links[ $code ] = sprintf(
            '<a href="%s" hreflang="%s" lang="%s">%s</a>',
            esc_url( get_permalink( $p ) ),
            esc_attr( $code ),
            esc_attr( $code ),
            esc_html( strtoupper( $code ) )
        );
    }

    if ( ! $links ) {
        return '';
    }

    return '<nav class="lang-switch" aria-label="Language">' . implode( ' ', $links ) . '</nav>';
}
Enter fullscreen mode Exit fullscreen mode

On redirecting by browser language: don't. A 302 to /de/ based on Accept-Language means a crawler arriving with an English header never sees the German tree, and shared links bounce people to the wrong language. Serve the page with 200 and show a dismissible banner instead.

Step 6: Verify, don't assume

Four checks that take a minute:

# 1. Both directions present and pointing at each other
for u in https://example.com/ https://example.com/de/; do
  echo "== $u"
  curl -s "$u" | grep -o '<link rel="alternate"[^>]*>'
done

# 2. Translated URL returns 200 and is not redirected
curl -sI https://example.com/de/ | head -n 5

# 3. Canonical is self-referencing
curl -s https://example.com/de/ | grep -o '<link rel="canonical"[^>]*>'

# 4. Switcher is in the HTML source
curl -s https://example.com/de/ | grep -c 'hreflang="en"'
Enter fullscreen mode Exit fullscreen mode

Then confirm that each language URL appears exactly once in the sitemap and that noindexed translations do not appear at all. If you exclude unreviewed pages with wp_sitemaps_posts_query_args, make sure the flag exists on every indexable page — otherwise you drop the whole site from the sitemap.

Checklist

  • [ ] One URL shape, decided before the first translation
  • [ ] hreflang reciprocal, self-referencing, exactly one x-default
  • [ ] Every translation self-canonical
  • [ ] noindex on unreviewed translations, removed after review
  • [ ] Titles and meta descriptions written per language, not translated literally
  • [ ] Switcher is crawlable <a href> links, only to pages that exist
  • [ ] No Accept-Language redirect on entry
  • [ ] One sitemap entry per language URL

If you'd rather not hand-roll steps 2, 4 and 5, AumLang is a free plugin that handles hreflang output, AI translation and WooCommerce product translation. Either way, run the four curl checks above — they tell you what is actually in the HTML, which is the only thing the crawler reads.


More free WordPress plugins we maintain: aumcreate.com/plugins

Top comments (0)