DEV Community

Cover image for 1,391 Tags Later, 931 Still Describe One Post Each
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

1,391 Tags Later, 931 Still Describe One Post Each

On 4 June I closed two doors at once. I pulled tag pages out of the sitemap — thin listings were inflating sixty per cent of it and eating crawl budget. The same day, for the same reason, I put a limit on the production side too: a canonical vocabulary of 45 tags and a cap of five tags per post. The commit message says exactly that: "restrict AI tag generation to a canonical ~46-tag vocabulary."

It worked. Two hundred posts shipped in July and added exactly one new tag to the vocabulary.

Then in September the tag count started climbing again, and it took me four months to notice. Today I sat down and counted: 1,370 Turkish posts, 1,391 distinct tags. There are more tags than there are posts.

The inventory first

Numbers from today's main tree, drafts excluded. The live feed.json gives the same — I counted both separately:

Measurement Value
Turkish posts 1,370
Distinct tags 1,391
Total tag usages 5,831
Tags per post mean 4.26 · median 5
Tags appearing on exactly one post 931 (66.9% of distinct)
Appearing on at most two posts 1,112 (79.9%)
Share of the 15 most frequent tags 1,809 usages = 31.0%

The head of the distribution is healthy: yazilim on 245 posts, operations 148, security 143, kariyer 141, linux 140. Fifteen tags carry a third of all usage. The problem isn't at the head; it's in the tail.

I also counted where the tail came from: of the 931 single-post tags, 708 sit on posts published before 4 June and 223 after. So the vocabulary I installed that day genuinely held. The problem is the legacy stock — and the fact that the gate stopped working four months later.

I didn't start deleting at this point. It felt more honest to ask something else first: who reads this field? Because calling something useless assumes someone was looking at it.

Who reads a tag?

I found eight places in the code that consume tags. I measured each one separately, because "tags don't do anything" is just a feeling until you check. Two of the results did not confirm what I expected.

Diagram

Related posts. The scoring is simple: same category +3, each shared tag +1, top three win. I have to be honest here: taken as a whole, tags work. In 1,358 of 1,370 posts at least one candidate rises above the category-only score thanks to a tag. In only 12 posts does the top score stay at 3 — and there the selection is decided by array order, because hundreds of candidates are tied. Throwing the field away would be wrong. But by definition, a tag that lives on one post can never enter this scoring; it has nobody to share with.

The tag page. I wrote this part up last week from the Search Console side, so I'll keep it short: the page ships noindex,follow, it left the sitemap on 4 June, and robots.txt closed it on 6 June. One detail from that post applies here too — and it demolishes what I'd been assuming was three layers of protection: per Google's own documentation, for noindex to work the page must not be blocked in robots.txt. A crawler that can't fetch the page never reads the noindex line. The second rule silences the first.

The practical result: a tag page is neither in the sitemap nor crawlable, which leaves the human click as its only real visitor. And when you open the page of a tag that lives on one post, what do you see? A heading saying "1 post", and below it a single card: the post you just left. 931 times.

A smaller finding: the card component prints only the first three tags, so 2,102 tag usages (36%) appear on no card at all. And tag links are generated without a trailing slash in four places (under the post, the homepage tag cloud, the blog listing); asking for /tags/olcum earns you a 301 to /tags/olcum/. Only the card component writes it correctly.

meta keywords and schema.org. Both are still in the live HTML: <meta name="keywords" content="yazilim-kariyeri, dokumantasyon, olcum, git, surec"> and the same array inside the JSON-LD. Google's documentation on supported meta tags isn't even polite about it: the keywords tag has "no effect on indexing and ranking at all." On the structured-data side, the properties they list for Article/BlogPosting are author, dateModified, datePublished, headline and image — keywords isn't there. Harmless; but the story I'd told myself — "I write tags for SEO" — has no ground under it.

Social hashtags. Here there are real consumers, two of them: the Bluesky and LinkedIn code each turn the first five tags into hashtags. 809 single-post tags fall inside that window and 804 sit on posts that were actually shared, so they went out. A hashtag's job is to take you into a feed; a hashtag with one post is a room with a door and nothing behind it.

Search. The search text is assembled from title + tags + description + body. A tag only contributes something new if it isn't already in the prose — and "already there" depends on your matching rule: under strict substring matching, 567 of the 931 are present in their own post's text; allowing Turkish suffixes and separator differences, 677. So somewhere between a quarter and a third of the tail genuinely adds a new searchable word — things like invisible workload or customer trust. That's a more honest sentence than "none of it contributes."

RSS, JSON Feed and llms-full.txt. I saved the real surprise for last, because before measuring I hadn't even counted these three. All of them emit every tag of every post, with no truncation: the <category> lines in RSS, the tags array in feed.json, the - Tags: line in llms-full.txt. The tail that the card hides and the hashtag window cuts at five is in full circulation here. And llms-full.txt is the very file my robots.txt policy deliberately invites AI assistants to read. So the widest consumer of the field I'd written off as unread turned out to be in the place I never looked.

When the gate stopped working

New tags by publication month — and I'm giving the table from the start, because the real story isn't where I'd cut it:

Month Posts New tags Per post
2026-04 368 602 1.64
2026-05 274 422 1.54
2026-06 232 51 0.22
2026-07 200 1 0.01
2026-08 97 49 0.51
2026-09 96 140 1.46
2026-10 71 70 0.99

Had I started the table at June, I'd have had a dramatic "September explosion" on my hands. Add the April and May rows and the story changes: September isn't an explosion, it's a return to the pre-4-June baseline. So the question isn't "what happened in September?" but "why did the gate I installed in June stop working?"

The answer is mechanical. The canonical vocabulary and the five-tag cap live inside the script that generates posts. On 3 September the topic queue ran dry — I wrote about that separately, because it broke my category rotation the same day — and posts started coming out of a different path that writes the file directly. That path never calls the old script, so it walks straight past the vocabulary and the cap. There are 14 posts carrying nine tags, and all of them are dated 19–28 September: by then the ceiling was already gone.

I installed that gate, and I didn't remove it. I just installed it inside the corridor — and when the corridor changed, the gate went with it.

The root cause sits one line away

So why did nothing catch it? Because the validation isn't in the schema. Inside src/lib/blog-frontmatter-schema.mjs, two neighbouring lines read:

category: z.enum(BLOG_CATEGORIES),      // closed: technology, tutorials, career, life
tags: z.array(z.string()).min(1),       // at least one — no ceiling, no vocabulary
Enter fullscreen mode Exit fullscreen mode

I locked category to four values. For tags I never wrote anything beyond "there should be at least one." Because the vocabulary decision lived in the generator rather than the schema, this file — which inspects every post on the publishing path — waved 1,391 distinct values through without complaint.

I'd seen a similar pattern when I tested my own instruction files against the code: the place nobody audits is the place that quietly drifts. The difference this time: the audit existed, it was just in the wrong layer.

Same concept, two separate tags

There's no normalisation either. Collapsing case, hyphen, space and Turkish-character differences, 180 clusters are represented by more than one spelling, covering 386 tags. (Drop the Turkish folding and you're left with 156 clusters / 333 tags — that fold is exactly what separates yazilim from yazılım.) The expensive ones:

  • devops on 124 posts · DevOps on 15
  • sistem-mimarisi 123 · sistem mimarisi 5 · Sistem Mimarisi 1
  • ai 74 · AI 21
  • indie-hacker 95 · indie hacker 10

The tag query uses exact matching (tags.includes(tag)), so these really are different things. I tried it live: /tags/DevOps/ and /tags/devops/ both return 200 and show two separate lists. In the related-posts score those two posts behave as though they share nothing. The hashtag formatter then carries the same split somewhere else: devops becomes #Devops while DevOps becomes #DevOps, yet sistem-mimarisi and sistem mimarisi both collapse into #SistemMimarisi.

There's a bilingual duplication on top: kariyer 141 / career 82, security 143 / guvenlik 51, rehber 118 / tutorial 3. Which one is correct is debatable; that using both is wrong isn't.

My own note misled me

Into the 11 May commit where I moved tag pages to SSR, I wrote this:

Prerendering tag pages per unique tag was producing ~100 pages.

In the same commit message I counted posts precisely: "985+ posts x 2 languages = ~1970 pages." I counted one field and estimated its neighbour. I checked out that commit's tree and counted: 910 Turkish + 915 English = 1,825 tag pages. My estimate was off by a factor of eighteen.

The fix still worked. But because of that "~100 pages" note I remembered the problem as a small one for months — until three and a half weeks later, on 4 June, I measured the same thing again and wrote the real number into the code. The rounded figure kept feeding the memory even while the measured one sat in a neighbouring file.

One more note: in my post on 20 October I wrote "1,261 distinct Turkish tags." Counting today from both the git tree and the live feed.json gives 1,391, and the tree on that date held 1,375. A gap that size can't close in six days, so I'll redo that measurement. The number in this post is the correct one.

Four questions I now ask before adding a field

For any metadata field — frontmatter, database column, tag, it doesn't matter:

  1. Can I name its consumers? I thought there were five; there are eight, and I'd never counted the three widest.
  2. Is the vocabulary closed? If not, why not — does it genuinely need infinite variety, or was I avoiding a decision?
  3. Which layer does the validation live in? My gate was inside a generation script, not beside the schema. The path changed and the gate went with it, and the schema felt nothing. Put the rule next to whatever records the data, not next to whatever produces it.
  4. Does the field leave the building? Even if I close the vocabulary today, the RSS history, the hashtags already posted and the crawlers that have fetched llms-full.txt are out of my hands. Metadata doesn't come back once it has escaped.

I also left myself two cheap numbers: the single-use ratio (66.9% here) and the share of the fifteen most frequent values (31.0%). If both climb at once, the vocabulary is coming apart.

What I'm going to do

Order matters, because clearing stock doesn't fix a mechanism. First I'll move the validation into the schema (an approved list plus lowercase normalisation), so that every path writes through the same gate. Then I'll merge the 180 spelling variants. Pruning the tail comes last — and the safety net for that is already in place: a tag address with no posts 301s to /blog/, so I can delete without creating dead links.

I'm not planning to remove tag pages altogether: the listing for a tag like olcum, with 62 posts, is a genuinely useful page. What needs removing isn't the page, it's the tail that makes the page meaningless.

The real lesson: I started writing this as "I kept filling a field nobody reads," and the measurement stopped me twice. I had closed the gate — but inside the corridor. And the widest reader of the supposedly unread field turned out to be three machine-facing files I had never looked at. Filling a field doesn't prove it's read; assuming it isn't read doesn't prove that either. Both are things you measure.

One small note: this post's own tags were picked from the existing vocabulary. All five have been used before; I didn't add a new one.

Official Sources

Top comments (0)