DEV Community

Cover image for My Sitemap Says tr-TR, My Pages Say tr
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

My Sitemap Says tr-TR, My Pages Say tr

This blog publishes every post in two languages. I write the Turkish, the English lands next to it in the same directory, and the two addresses become each other's twin. The code that does this arrived in a single commit on the evening of 8 May 2026. It has been touched twice since, but I had never once measured what it actually says.

Then a question came up: there is a way to tell Google "the Turkish of this page is here, the English is there," and I do it in two separate places. One is the page's own <head>, the other is the sitemap. Do the two say the same thing?

This morning I ran the measurement. Most of it came back flawless: all 1,398 pairs are in place, every address is alive. But the two channels don't use the same language code, once I sat down to count I found I write the same sentence in four separate places, and 2,789 pages nobody ever decided about are declaring themselves bilingual too.

What the sitemap says: 2,827 addresses, 1,413 pairs

When I ask for sitemap.xml I get a 301 to sitemap-index.xml, and from there a single sitemap-0.xml: 1,303,174 bytes, 2,827 <url> entries.

2,826 of those carry two extra lines inside — the xhtml:link lines of Google's sitemap extension. The sitemap uses exactly two distinct hreflang values:

2826  hreflang="tr-TR"
2826  hreflang="en-US"
Enter fullscreen mode Exit fullscreen mode

So the map declares 1,413 distinct Turkish targets and 1,413 distinct English ones. Google's rule is that every address in the cluster has to appear in the map with its own entry, and that entry has to list all versions including itself. I tested that: not one of the 2,826 declared targets is missing, and the number of non-reciprocal pairs is zero.

Then I checked whether the addresses are alive. I sent a HEAD request to all 2,827 of them, 8 workers in parallel, 92 seconds:

Counter({200: 2827})
redirects: 0
Enter fullscreen mode Exit fullscreen mode

All 200. Not a single redirect, not a single 404. The repository side says the same: under src/content/blog there are 1,398 Turkish and 1,398 English files; the number of Turkish posts without an English twin is zero, and so is the reverse.

So far, so good. The version of me from May did the job.

What the pages say: tr, en, x-default

Then I looked at what the pages declare themselves. I picked 40 random posts (Turkish and English mixed), pulled the <head> of each, and checked four things: are the three hreflang lines there, does the page list itself, are the targets right, does canonical point at the page's own address.

40 out of 40 are clean. Every page prints this:

<link rel="alternate" hreflang="tr" href="https://mustafaerbay.com.tr/blog/life/.../">
<link rel="alternate" hreflang="en" href="https://mustafaerbay.com.tr/en/blog/life/.../">
<link rel="alternate" hreflang="x-default" href="https://mustafaerbay.com.tr/blog/life/.../">
Enter fullscreen mode Exit fullscreen mode

Google's strictest rule is satisfied here: every version has to list itself as well, and if two pages don't point at each other the tags are ignored. It is good that the rule is strict — otherwise some stranger's site could declare my post to be its own English version.

That's why I counted canonical too. On bilingual sites the most common way the whole thing collapses is a canonical in one language pointing at the other: at that moment the cluster is gone, because Google has already read one of the two addresses as "a copy of the other." On all forty pages the canonical points at the page's own address; that door is shut.

The difference is this: the page says tr and en, the sitemap says tr-TR and en-US.

Diagram

The two channels don't share a vocabulary

Finding the source of that difference didn't take long: it sits in two files, and both were written the same evening. Commit 8047dfd5, dated 8 May 2026, solves exactly one problem according to its message — Google not seeing the Turkish and English versions as alternates of each other, the two versions cannibalizing one another. The same commit adds the three lines to the page head and puts en: 'en-US' into the sitemap setting.

The sitemap setting in astro.config.mjs hands out language codes with a region suffix:

i18n: {
  defaultLocale: 'tr',
  locales: { tr: 'tr-TR', en: 'en-US' },
},
Enter fullscreen mode Exit fullscreen mode

src/layouts/BaseLayout.astro, meanwhile, writes the lines printed into the page head by hand, without regions: tr, en, x-default. Two files, two vocabularies, no link between them. If I change one the other won't hear about it — and I already know that failure mode, because the list that keeps noindex pages out of the sitemap has the same illness.

Per Google's documentation both codes are valid: the language code must come from ISO 639-1 and the region code from ISO 3166-1 Alpha 2, you may write the language on its own, but you may not write the region on its own. tr follows the rule, and so does tr-TR. The problem here isn't validity.

The problem is the width of the claim. In Google's own table en means "English content," while en-US means "English content for users in the US." Nobody reading my English posts is required to be in Texas; my sitemap reports a narrower target to Google than my pages do. On top of that, the sitemap has no x-default line at all — so my third declaration exists in only one of the two channels.

The one clear sentence the documentation offers is this: the three methods (HTML, HTTP header, sitemap) are equivalent as far as Google is concerned, and there's no benefit in using them all at once. I don't use the third method, the Link HTTP header, at all; that one earns its keep for files that aren't HTML — PDFs, feeds — and I have no twins of that kind. What happens when two channels disagree, the documentation doesn't say. Neither can I; I didn't look at Search Console on this run, all I have is my own site's declarations. What is certain: I'm running a second channel with no upside, and that channel says something different.

A third and a fourth place

While counting two channels I had forgotten the third. Every post page also prints Open Graph tags:

<meta property="og:locale" content="tr_TR">
<meta property="og:locale:alternate" content="en_US">
Enter fullscreen mode Exit fullscreen mode

Here the spelling uses an underscore: tr_TR. That isn't a mistake — Open Graph's own format works that way. The fourth one is in the page body: the language switch link beside the post and the metadata line under it both carry rel="alternate" hreflang="en". I counted those links on ten random pages: two per page, and of the twenty, ten end with a trailing slash and ten don't.

body alternate links on 10 pages: 20
with trailing slash: 10   without: 10
Enter fullscreen mode Exit fullscreen mode

When I ask for the one without the slash, the server sends a 301 to the slashed address. So inside the body of the post there is a link that tells Google "my English lives here" and points at an address that redirects.

That detail has a history, and the comment in BaseLayout.astro recorded it: for a while canonical was adding a trailing slash to addresses while hreflang wasn't. Google counted the two as separate addresses; in the Search Console report of 15 May 2026, 867 pages landed in the "alternate page with proper canonical tag" bucket. Three days later, on 18 May, the head addresses were normalized (bc30f2b6). The body link was left alone — because at the time nobody thought of the body as a declaration channel. Neither did I, until today.

There is a fifth place, but I'm not counting it: <html lang="tr">. That one states only the page's own language and says nothing about a twin. And Google is explicit on this point: it doesn't work out a page's language from hreflang or from the lang attribute — it reads the text. The job of these tags isn't to identify the language; it's to tie two versions together.

I wired x-default to Turkish

x-default is the fallback that kicks in when no version matches the user's browser setting. Google designed it for language selector pages: for sending the user to the place where you ask them which language they want.

In every one of my clusters, x-default points at the Turkish page. The comment in the code even states the reasoning: "x-default = TR (default audience)".

Today I find that debatable. A reader from Germany whose browser is set to German matches neither Turkish nor English; they fall through to the fallback and get a Turkish page. Yet that post has an English version — all 1,398 of them do. Serving Turkish to someone who doesn't read Turkish, while the English sits right there, isn't hospitality.

A preference, not a bug. But I didn't know I had been making that choice for six months, and the difference matters.

2,789 pages that aren't in the map declare a language too

This is where the measurement surprised me.

BaseLayout.astro ties the three lines to one switch, and the switch defaults to on:

const { ..., hasTranslation = true } = Astro.props;
Enter fullscreen mode Exit fullscreen mode

So unless a page says otherwise, it says "I have a twin." And nothing says otherwise: the only call site in the repository is BlogPost.astro, which writes hasTranslation={!!translation}. Every other page type — tags, series, listings — never passes the switch at all and takes the default. When I add a new page type, the language declaration comes for free; it comes whether I want it or not.

The tag pages are the live proof. The published posts carry 1,394 distinct Turkish tags and 1,395 distinct English ones; each gets an address. Those addresses are not in the sitemap, they carry noindex, and robots.txt closes them off. Each one still prints three hreflang lines. Roughly 8,367 lines — a number I did not measure but derived from 2,789 pages × 3 lines.

To put that in proportion: by the same arithmetic the 2,826 pages that are in the map print 8,478 lines. My pages that never appear in the map produce as much language declaration as all the pages that do.

Tag pages have a separate problem between noindex and robots.txt; I wrote about that in the post where I read my Search Console report and won't repeat it here. The question here is the language declaration itself.

135 tags point at a twin that doesn't exist

The tag vocabulary isn't identical across the two languages: in today's count, 67 tags that appear in Turkish posts have no counterpart on the English side, and 68 English tags have none on the Turkish side. I wrote about why the vocabulary grew unchecked in the post where I counted my tags; that the two languages' lists had drifted apart is something I counted for the first time today.

Each of those 135 addresses points at a twin that doesn't exist. I asked every one of them:

own page:            200  ×135
declared alternate:  301  ×135
target: /blog/  68    /en/blog/  67
Enter fullscreen mode Exit fullscreen mode

So the /tags/Güvenlik/ page says "my English is at /en/tags/Güvenlik/"; since no English post carries that tag, the server — as I noted once before, so that empty tag addresses don't produce dead links — sends a 301 to the English blog listing. And that listing, naturally, doesn't declare a tag page as its alternate; it points at /blog/ as its own twin.

Google's rule is clear here: if two pages don't point at each other, the tags are ignored. Which means those 135 declarations do nothing at all. Is there harm? None that I can show; the addresses aren't 404s, there's no redirect loop, each lands on the blog page in a single hop. They're just lines printed for nothing. The trouble with lines printed for nothing is that they become noise on the day you actually need to look.

My own edge rate-limited me

On my first attempt I asked for the 135 tag addresses with 8 parallel workers and got a 429 from 127 of them:

HTTP/2 429  retry-after: 9  server: cloudflare
Enter fullscreen mode Exit fullscreen mode

My first instinct was to read that as "my tag pages are broken." They aren't: what returns the 429 is the Cloudflare layer in front of my own site. But the reason wasn't my first guess either. I ran two scans of the same severity:

30 post addresses, 8 workers:  200 ×30            cf-cache-status: EXPIRED ×30
30 tag addresses,  8 workers:  200 ×16  429 ×14   cf-cache-status: MISS ×15
Enter fullscreen mode Exit fullscreen mode

Both page types are generated server-side and carry the same cache-control header. The difference is that post addresses are already at the edge (EXPIRED, i.e. being revalidated) while tag addresses aren't there at all (MISS) and the request goes down to the origin. But that alone isn't what trips the limit — in my own sweep I asked for 2,827 post addresses at the same concurrency, around thirty requests a second, and never saw a single 429. So the rate limit is a rule set on the /tags/ path; I haven't yet opened and read that rule on the Cloudflare side. That's as far as I can go.

I knew the lesson in advance: the measuring script itself produces findings. Had I written up the 429, half the post would have been wrong. With a serial run — 0.6 seconds between requests, a 5-second wait and a retry on each 429 — I got an answer from all 135. The table above is that run's output.

Writing a measurement script that leans on your own site is like a guard dog that doesn't know its owner's address: it's doing the work, to the wrong person.

A safety catch that has never fired

Of the 2,827 addresses in the map, exactly one carries no hreflang pair: /hakkimda/. I first read that as "so the safety catch works," then opened the file and saw I was wrong.

src/pages/hakkimda.astro is a hand-written document that sits outside the layout: it prints its own <!doctype html>, meta-refreshes to /about, and says noindex. It never uses BaseLayout. So the reason it prints no hreflang isn't a decision — it's that the page never passes through that code at all. There's no og:locale on it either, and no analytics; all for the same reason.

So what happened to the switch that was written for pages without a twin? It was added on 6 July 2026, and its comment describes a real problem: a page without a twin, declaring an English alternate that leads to a 404, produced a broken-hreflang soft error on Google's side. But today the switch has exactly one call site in the repository, and that site writes hasTranslation={!!translation}. Since every post has a twin — 1,398/1,398 — that expression has never once been false. The safety catch has been fitted for four months and has never fired.

/hakkimda/ has one more flaw that doesn't belong in this post: despite saying noindex, it is still in my sitemap. I wrote that up in the audit at the end of September and it is still open. Clean on language, not on the map.

What I'm changing

Five jobs come out of this measurement. I'm doing none of them in this post; the post is the record of the measurement, and the fixes will be separate changes.

  1. Flip the default. The hasTranslation default should be false, and a page that wants a language declaration should ask for it explicitly. It's first on the list because I had the order backwards: while I was thinking "close one of the two channels," the measurement told me the thing to close isn't a channel, it's a default.
  2. Remove the language declaration from tag pages. One hasTranslation={false} each in src/pages/tags/[tag].astro and src/pages/en/tags/[tag].astro — I'm naming the files so February's run can check it.
  3. Normalize the body link. The head addresses were fixed on 18 May; carrying the same fix into the body is a one-line job.
  4. Make the two vocabularies agree. The map says tr-TR/en-US, the page says tr/en. The measurement says the opposite of my instinct: all 2,826 pairs in the map are reciprocal, and the map left out the one page with no twin by itself. The channel declaring indiscriminately is the page head. So I'm no longer saying "drop the map." I'll do 1 and 2 first to clean the page channel, then bring both channels onto one vocabulary. Deleting the map's pairs outright is a separate decision with a cost: those lines went in on 8 May to stop the two languages cannibalizing each other, and if I throw them away I owe an answer about what replaces that reason.
  5. Point x-default at English. For the unmatched reader, the best welcome I have isn't Turkish. But there's something to measure before I change it: the Accept-Language distribution of my visitors. I don't know how many people fall through to that fallback, and changing it without knowing goes against this blog's own rule.

I'll check all five by repeating this measurement in February 2027; if the numbers haven't moved on that run, what I've written here stays a statement of intent.

So what is this worth? The only customers of these tags are search engines, and this blog's search traffic is modest to begin with. The work here isn't a gain that will bring readers flooding in. The value sits elsewhere: having every sentence a system says to the outside world be true is a habit that pays on the days nobody is reading. A site that says three different things from four places can't trust its own declarations on the day it genuinely has to debug something.

Closing

The number this audit produced isn't 1,398/1,398 or 2,827/2,827. The number is 2,789: I have declared a language on pages I never decided anything about.

What we call a declaration in infrastructure — hreflang, canonical, robots, lastmod — is always a claim: it tells a machine out there "here is my situation." The moment a claim has a default, it stops being claimed and starts merely being said. My code speaks correctly for 1,398 posts, because every one of those pairs came from a decision. It speaks with the same confidence for 2,789 tag pages, because that's what the default does. The difference between the two is a difference the code doesn't know about.

Had I read the lines written back in May without measuring them today, I'd have found them all correct. Measuring is the shortest way to stop agreeing with your own code.

Official Sources

Top comments (0)