Mert Özen Further With Every Line
SEO & GEO Content SEO — #04

Content Architecture: Topic Clusters, Series and Category Structure, and Keyword Cannibalization

Mert Özen Nov 14, 2026 10 min 2 views
Content Architecture: Topic Clusters, Series and Category Structure, and Keyword Cannibalization

A good content architecture gives every query intent a single owner on the site and makes hub pages actually explain a topic; two pages that target the same intent weaken each other through keyword cannibalization. We cover mertozen.com's category, series and post structure, tag inflation, finding cannibalization candidates from Search Console data with .NET 10, and the merge, differentiate and canonical options.

Version note: The code in this post was built and run with .NET 10; the post is based on the November 2026 state of Google's SEO Starter Guide and its documentation on multilingual sites and canonicalization.

Guess First

Two pages of the Bookstore blog show up for the Turkish query "ef core migration geri alma", which asks how to roll back a migration: the general EF Core migration guide and a short post dedicated to rolling back. Both bounce between positions 7 and 10, and neither makes it into the top five. The team has three proposals: (1) put a canonical from the short post to the guide; (2) merge the two posts and 301-redirect the old URL; (3) do nothing, since two pages mean twice the visibility. There is a separate debate too: should every post get 5–7 tags, with a page for each tag? Which proposal strengthens both pages, and which one makes things worse?

Why Content Architecture Matters

Content architecture is how the pages of a site are grouped and linked to each other. From an SEO point of view it does two jobs: it helps Google understand the relationships between pages, and it keeps pages that answer the same question from getting in each other's way.

Google's SEO Starter Guide is measured on this. A logical site structure helps both users and search engines understand how pages relate to each other; but the guide adds that there is no need to rush into restructuring, since search engines can understand your current structure. On large sites with thousands of URLs, grouping similar topics in directories can help Google learn how often each section changes. So architecture is not a ranking trick; it is a by-product of an orderly site.

Hubs and Clusters

A common model in the SEO industry is the topic cluster: a hub (pillar) page that gives a broad overview of a topic and cluster pages that answer the topic's sub-questions in detail. The hub links to every cluster page, and every cluster page links back to the hub and, where it makes sense, to each other. These terms do not appear in Google's documentation; they are industry practice. The idea behind them, however, matches what Google describes: links provide both discovery and context, and every important page should get a link from another page on the site (more on this in the next post).

A technical blog has a ready-made equivalent of this model: the series. The series page is the hub and the posts of the series are the cluster. Because a series takes the reader through a topic in a set order, it builds the cluster structure on its own; what remains is to make the hub a real hub.

Category → Series → Post on mertozen.com

This site's structure has three layers. At the top are categories such as Backend, Frontend and DevOps, under each category are series, and under each series are posts. More than a thousand posts sit in this tree.

04-content-architecture-en.png

The URLs reflect this tree as follows: posts live at /blog/{slug}, while category and series pages are filtered views of the blog list, such as /blog?category=seo-ve-geo and /blog?series=.... The EN versions are the same paths with the /en prefix. A post's breadcrumb shows the chain Home / Blog / Category / Series / Post, and the page's BreadcrumbList JSON-LD carries the same chain. The post page has a side panel listing all posts of the series, previous and next post cards, and a related posts section. So the cluster side is strong: every post is linked to its hub and its siblings.

The weak side is the hub itself. Looking at the code base, the series view uses the same title ("Blog — Mert Özen") and the blog list's general description for every series. Each series has a description field in the CMS, but the series view does not show it; readers and Google see only a list of cards on a series page. This is exactly the "leaving the hub page as an empty list of links" pitfall. The fix is small: generate the series view's title and meta description from the series name and description, and put an introductory paragraph above the list that says what the series teaches and in which order to read it. We discussed the pros and cons of using query strings instead of paths for category and series views in the URL design post of the Technical SEO series.

Taxonomy and Tag Inflation

Taxonomy is the system you use to classify content: categories, series and tags. Categories and series are few and controlled; tags multiply easily. When we counted the metadata of 725 of this site's published posts, we found 1,222 distinct tags, 819 of which are used on a single post only. Tags appear on the post page as /blog?tag=... links, and the canonical of these views keeps the tag. The result: hundreds of list pages that consist of a single post card.

These pages tell neither readers nor Google anything. The options: limit tags to a controlled list (require every tag to appear on at least a few posts), give tag pages with few posts noindex, follow, or stop rendering tags as links and show them only as information inside the post. Whichever you choose, the rule is this: a taxonomy page should exist only if it gathers enough content for a reader to actually want to browse it.

Keyword Cannibalization

Keyword cannibalization is when two or more pages on the same site that target the same intent take each other's place for the same query. This, too, is not a term from Google's documentation but the industry's name. The symptom is familiar: for a query, sometimes one page appears and sometimes the other; both stay in middling positions; clicks are split. Google cannot decide which of the two pages is the better answer, because you have not decided either.

Diagnosis starts in Search Console: if you filter for a query in the Performance report, switch to the Pages tab and see more than one URL for the same query, you have a candidate. But you cannot do that by hand for hundreds of queries. Let's extract the candidates using the sample export from the second post of the series (query and URL on the same row). The SearchRow record is the same as in the second post.

using System.Globalization;
using Bookstore.ContentSeo;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
string path = args.Length > 0 ? args[0] : "gsc-url-query.csv";
List<SearchRow> rows = [.. (await File.ReadAllLinesAsync(path)).Skip(1).Select(SearchRow.Parse)];
const double MinShare = 0.15;

static string LangOf(string url) => url.StartsWith("/en/") ? "en" : "tr";

foreach (IGrouping<string, SearchRow> query in rows.GroupBy(r => r.Query).OrderByDescending(g => g.Sum(r => r.Impressions)))
{
    int total = query.Sum(r => r.Impressions);
    // Pages that get a meaningful share of the query's impressions
    List<SearchRow> pages = [.. query.Where(r => (double)r.Impressions / total >= MinShare)];
    if (pages.Count < 2)
    {
        continue;
    }

    // Pages in the same language compete; a TR/EN pair is a pair of hreflang alternates
    List<IGrouping<string, SearchRow>> sameLanguage = [.. pages.GroupBy(r => LangOf(r.Url)).Where(g => g.Count() > 1)];
    if (sameLanguage.Count == 0)
    {
        Console.WriteLine($"skip  \"{query.Key}\": {string.Join(" + ", pages.Select(p => $"{LangOf(p.Url)} {p.Url}"))} (language alternates)");
        continue;
    }

    Console.WriteLine($"CHECK \"{query.Key}\" ({total} impressions)");
    foreach (SearchRow p in sameLanguage.SelectMany(g => g).OrderByDescending(p => p.Impressions))
    {
        Console.WriteLine($"      {p.Url,-36}{p.Impressions * 100 / total,4}%  clicks {p.Clicks,3}  pos {p.AvgPosition,4:F1}");
    }
}

Output of dotnet run:

skip  "dependency injection": tr /blog/dependency-injection-nedir + en /en/blog/what-is-dependency-injection (language alternates)
skip  "ef core migration": tr /blog/ef-core-migration-rehberi + en /en/blog/ef-core-migrations-guide (language alternates)
skip  "cs8618": tr /blog/cs8618-nullable-uyarisi + en /en/blog/cs8618-non-nullable-property-warning (language alternates)
CHECK "ef core migration geri alma" (500 impressions)
      /blog/ef-core-migration-rehberi       52%  clicks   7  pos  9.8
      /blog/ef-core-migration-geri-alma     48%  clicks  12  pos  7.5
CHECK "dependency injection lifetime" (300 impressions)
      /blog/dependency-injection-nedir      53%  clicks   4  pos 10.4
      /blog/aspnet-core-di-lifetime         46%  clicks   5  pos  9.6
skip  "dotnet ef migrations add": en /en/blog/ef-core-migrations-guide + tr /blog/ef-core-migration-rehberi (language alternates)

The program skipped four queries because one page is TR and the other EN: these are two language versions of the same content, linked to each other with hreflang. Two queries are real candidates. For "ef core migration geri alma" the impressions are split almost in half between two TR pages, and neither gets above position 7. "dependency injection lifetime" shows the same picture: the definition page and the lifetime page compete for the same query.

A caveat: the total for a single period does not show the "now this one, now that one" swing over time. Looking at which URL appeared for the query on which day, using the daily data in the bulk export, is the best way to confirm a candidate.

Solution Options

Cannibalization has no single fix; the choice depends on whether the two pages really say the same thing.

SituationFixWhat happens
Two pages answer the same intent with the same contentMerge and 301-redirect the old URLA single, strong page; the old URL's signals are consolidated on the new page
Two pages serve different intents but the boundary is blurredDifferentiate: sharpen each page's focus, title and headings, and add a descriptive link between themEach page gets stronger for a different query
Two URLs show the same content (parameters, sorting, print version)CanonicalGoogle is told which URL is preferred
The page is needed by readers but should not be in searchnoindexThe page stays on the site and leaves the index

It matters to understand where canonical fits. A canonical is a signal for different URLs of the same or nearly the same content; Google treats it as a hint and can ignore it when the pages really differ. Canonicalizing one of two separate posts to the other, Bookstore's first proposal, either does nothing or drops the short post out of the index and loses its content. We covered the mechanics of canonicals in the Technical SEO series' canonical post, and 301s in its redirects post.

Bookstore's decision: rolling back is a separate intent, so differentiate. The guide's rollback section shrinks to two sentences and links to the separate page with the anchor text "rolling back an EF Core migration"; the separate page's title and first paragraph answer the rollback question directly. On the dependency injection side, the definition page leaves the lifetime topic to a single paragraph and links to the lifetime page. The third proposal, doing nothing, was the worst: two middling positions are not worth more than one good one.

Thin and Near-Duplicate Content

On a multilingual site the first question is whether TR and EN pages count as duplicates. Google's documentation on multilingual sites is clear: localized versions of a page are considered duplicates only if the main content is left untranslated. So a page whose menu is translated but whose body stays in the same language is a problem; TR and EN versions whose body is really translated are not. Linking the two with hreflang tells Google which one goes to which reader; we implemented that in the Technical SEO series' hreflang post.

The real problem is nearly identical posts in the same language. If ".NET 8'de Minimal API ile Validation" and ".NET 9'da Minimal API ile Validation" are the same apart from a few lines, the two pages compete for the same intent and both add little. Thin content is a similar problem: one-paragraph pages that cover a single command and could be a section of another post. Each looks harmless on its own, but as the site grows they make up a large part of the index.

In Production

Check the keyword map when you open a new post. When a new post idea comes up, the first question is whether that intent already has an owner on the site. If it does, improving the existing page is usually better than writing a new post.

Run the cannibalization report regularly. The program above can run as a monthly job and open new candidates as issues. Tune the threshold (15%) to your own data.

Measure when you merge. After a merge and a 301, traffic usually fluctuates for a few weeks; add an annotation in Search Console and compare before and after over periods of the same length. We cover this in detail in the last post of the series.

Write content for the hub page. A series or category page should be a page with its own title and description, and an introduction that says what it teaches, who it is for and in which order to read it.

Common Pitfalls

Opening a separate post for every small topic. When "listing migrations in EF Core", "turning a migration into a SQL script" and "renaming a migration" become separate posts instead of sections of one guide, each stays thin and all of them compete in the same query family.

Leaving the hub page as an empty list of links. A list of cards tells readers "these posts are here", but does not answer "why does this topic matter, where should I start". For Google there is no text that says what the page is about either.

Trying to solve cannibalization with a canonical. Canonicals are for duplicate URLs; for two different posts the right tool is merging or differentiating.

Mistaking a TR/EN pair for cannibalization. If two versions in different languages show up for the same query, it means hreflang is doing its job or the query's language is unclear; removing one page is not the fix.

A Small Experiment

★☆☆ In Search Console, filter your site's top five queries one by one and look at the Pages tab. For how many of them is there more than one URL in the same language?

★★☆ Add a step to the Cannibalization program that fetches the titles and first paragraphs of the two pages for each candidate over HTTP and prints them side by side. Which candidates call for merging and which for differentiating?

★★★ Using the daily data of the bulk export, write a query that counts how many times the URL with the most impressions for the same query changes from day to day. Queries with many changes are the strongest sign of real cannibalization.

We built the skeleton of the architecture: categories, series, posts and the relationships between them. What holds this skeleton up is links. In the next post we look at internal linking: anchor text, orphan pages, link depth and building your own link graph in C#.