| name | seo-topical-clusters |
| description | Build topical authority with pillar/cluster (hub-and-spoke) content architecture, internal-linking strategy, content-gap analysis, and cannibalization avoidance โ with Next.js App Router internal-linking patterns and a coverage-scoring rubric. Use when planning content architecture, building topical authority, designing a pillar page with supporting cluster pages, fixing keyword cannibalization, or auditing internal links. Triggers on: topic cluster, content cluster, topical authority, pillar page, hub and spoke, internal linking, content gap, keyword cannibalization, semantic clustering, content architecture, orphan pages.
|
| version | 1.0.0 |
SEO Topical Clusters
Build topical authority โ search engines reward sites that cover a topic
comprehensively and interlink it coherently, not sites with scattered one-off posts.
The unit of authority is the cluster: one broad pillar page plus several
focused spoke pages, all interlinked, each owning a distinct query.
This skill covers: how to scope a cluster, group keywords by real SERP behaviour
(not text similarity), avoid cannibalization, wire internal links in Next.js, find
content gaps, and score topical coverage with a falsifiable rubric.
When to use
- Planning a new content section and you want it to rank as a topic, not isolated pages.
- Two pages compete for the same query (cannibalization) and rankings flip-flop.
- A page ranks but has no supporting context, or is an orphan (no internal links in).
- Auditing whether a topic is "fully covered" or has gaps competitors fill.
If the task is single-page on-page optimization, schema, or Core Web Vitals, use the
respective dedicated skill instead.
Core model: hub and spoke
โโโโโโโโโโโโโโโโโโโโ
โ PILLAR PAGE โ broad head term, 2500-4000 words
โ /guide/topic โ links OUT to every spoke
โโโโโโโโโฌโโโโโโโโโโโ receives a link FROM every spoke
โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโ
โผ โผ โผ
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ
โ spoke A1 โโโโโบโ spoke A2 โ โ spoke B1 โ focused intent, 1200-1800 words
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ spokeโspoke within a cluster
cluster A (subtopic) cluster B
- Pillar: targets the broad, high-volume head term. Comprehensive but not
exhaustive โ it summarizes each subtopic and links to the spoke that goes deep.
- Spoke: targets one specific intent/long-tail query. Goes deep on one thing,
links back up to the pillar and sideways to sibling spokes.
- Cluster: a subtopic group of 2-4 spokes. A pillar has 2-5 clusters.
Rule of thumb sizes (adjust to competition, don't pad to hit a number):
| Page type | Word count | Internal links out (in-body) |
|---|
| Pillar | 2500-4000 | one to each spoke |
| Spoke | 1200-1800 | 1 to pillar + 2-3 siblings |
Step 1 โ Scope the topic and expand keywords
Start from one seed (a head term or a URL you want to rank). Expand to 30-50 candidate
queries using whatever research tooling you have (use your own crawler/keyword tool,
or manual SERP inspection):
- Related / "people also search for" from the seed's SERP.
- People Also Ask (PAA) questions โ each is a potential spoke.
- Long-tail modifiers:
best, how to, vs, for beginners, tools,
examples, template, mistakes, checklist.
- Question variants: who / what / when / where / why / how.
- Intent modifiers:
pricing, review, alternative, comparison, free, top.
Deduplicate (lowercase, strip articles, remove near-identical). Target 30-50 unique
candidates. If under ~30, run a second pass seeding from the top PAA questions.
Step 2 โ Cluster by SERP overlap, not text similarity
The key idea (and the most defensible one): group keywords by how Google actually
ranks them. Two queries belong on the same page only if Google already serves the
same pages for both. Text similarity lies; SERP overlap does not.
Method โ for each candidate pair, fetch the top-10 organic results for both
queries (ignore ads, the featured snippet, PAA, map packs) and count shared URLs:
| Shared top-10 URLs | Relationship | Action |
|---|
| 7-10 | Same page | Merge โ one page targets both (prevents cannibalization) |
| 4-6 | Same cluster | Distinct spokes under one subtopic, interlinked |
| 2-3 | Interlink | Adjacent clusters; add 1 cross-link |
| 0-1 | Separate | Different cluster, or drop from this topic |
Keep it cheap. Full pairwise on 40 keywords = 780 comparisons. Don't. Instead:
- Pre-group by intent (Step 3) first โ ~4 groups of ~10 โ 4 ร 45 = 180 comparisons.
- Only cross-check keywords at group boundaries.
- Assume long-tail variants of the same head term share a cluster (skip the compare).
If you have no SERP-data access, fall back to intent + manual judgment, and flag the
plan as "intent-only clustered" so it's revisited when SERP data is available.
Step 3 โ Classify intent (and drop what doesn't belong)
| Intent | Signals | In cluster? |
|---|
| Informational | how, what, why, guide, tutorial, learn | Yes |
| Commercial | best, top, review, comparison, vs, alternative | Yes |
| Transactional | buy, price, pricing, demo, sign up, get started | Yes (usually a landing page, not a blog spoke) |
| Navigational | brand/product names, login | No โ exclude |
Classify by dominant intent for mixed queries ("best CRM software" โ commercial).
Intent also drives the page template:
| Intent pattern | Page template |
|---|
| Informational, broad | ultimate guide (often the pillar) |
| Informational, "how" | how-to |
| Informational, list | listicle |
| Commercial, compare | comparison / "X vs Y" |
| Commercial, rank | best-of |
| Transactional | landing page (Server Component, CTA-led) |
Step 4 โ Cannibalization avoidance
Cannibalization = two of your own pages competing for one query. Google picks one
(often the weaker), splits link equity, and rankings oscillate.
Prevention rules:
- No two pages share the same primary keyword. One query โ one canonical page.
- If SERP overlap between two candidates is 7+, they are the same page โ merge them.
- Pillar targets the head term; spokes target long-tails. The pillar must not try to
rank for a spoke's exact long-tail (it links to the spoke instead).
How to detect existing cannibalization on a live site:
- For the suspect query,
site:yourdomain.com "query" and see how many pages match.
- In Search Console, check if multiple URLs get impressions for the same query with
none holding a stable position โ that flip-flop is the tell.
- Fix by: merge + 301 the loser โ winner, or differentiate intent and re-point
internal links/anchors to the page you want to win.
Step 5 โ Internal-link matrix
Links are how authority flows and how Google understands the cluster. Design them
explicitly; don't leave it to chance.
| Link | Direction | Requirement |
|---|
| Spoke โ pillar | spoke โ pillar | Mandatory, every spoke |
| Pillar โ spoke | pillar โ spoke | Mandatory, every spoke |
| Spoke โ spoke (same cluster) | bidirectional | 2-3 per spoke |
| Cross-cluster | spoke โ spoke (other cluster) | 0-1, only if SERP overlap 2-3 |
Rules:
- Every page has โฅ 3 incoming internal links. Zero orphans.
- Every spoke is reachable from the pillar in โค 2 clicks.
- Anchor text = target keyword or a close variant. Never "click here" / "read more".
- Links live in body content, not only in nav/sidebar/footer (footer links carry
little topical signal).
Represent the plan as an adjacency list so it's checkable and renderable:
{
"pillar": { "slug": "topic", "keyword": "topic" },
"clusters": [
{ "name": "Subtopic A", "spokes": [
{ "slug": "topic-how-to", "keyword": "how to topic", "template": "how-to" },
{ "slug": "topic-examples", "keyword": "topic examples", "template": "listicle" }
]}
],
"links": [
{ "from": "topic", "to": "topic-how-to", "type": "mandatory", "anchor": "how to topic" },
{ "from": "topic-how-to", "to": "topic", "type": "mandatory", "anchor": "topic" },
{ "from": "topic-how-to", "to": "topic-examples", "type": "sibling", "anchor": "topic examples" }
]
}
Step 6 โ Implement in Next.js (App Router + RSC)
Internal linking in a content cluster should be data-driven, not hand-typed in
every MDX/post โ that's how you avoid orphans and broken anchors as the cluster grows.
Model the cluster as data (Payload CMS collection, MDX frontmatter, or a typed
config) so links are derived, not duplicated:
export type Spoke = {
slug: string;
title: string;
keyword: string;
cluster: string;
template: 'how-to' | 'listicle' | 'comparison' | 'best-of' | 'landing-page';
siblings?: string[];
};
export const pillar = { slug: 'topic', title: 'The Complete Guide to Topic' };
export const spokes: Spoke[] = [
{ slug: 'topic-how-to', title: 'How to Topic', keyword: 'how to topic',
cluster: 'Basics', template: 'how-to', siblings: ['topic-examples'] },
{ slug: 'topic-examples', title: 'Topic Examples', keyword: 'topic examples',
cluster: 'Basics', template: 'listicle', siblings: ['topic-how-to'] },
];
Pillar links out to every spoke (RSC, no client JS, fully crawlable <a>):
import Link from 'next/link';
import { spokes } from '@/content/clusters/topic';
export default function PillarPage() {
return (
<article>
{/* ...pillar body... */}
<nav aria-label="In this guide">
<ul>
{spokes.map((s) => (
<li key={s.slug}>
<Link href={`/guide/${s.slug}`}>{s.keyword}</Link>
</li>
))}
</ul>
</nav>
</article>
);
}
Every spoke links back to the pillar + its siblings via a shared component:
import Link from 'next/link';
import { pillar, spokes } from '@/content/clusters/topic';
export function ClusterLinks({ current }: { current: string }) {
const me = spokes.find((s) => s.slug === current);
const siblings = spokes.filter((s) => me?.siblings?.includes(s.slug));
return (
<aside aria-label="Related">
<Link href={`/guide/${pillar.slug}`} rel="up">{pillar.title}</Link>
{siblings.map((s) => (
<Link key={s.slug} href={`/guide/${s.slug}`}>{s.keyword}</Link>
))}
</aside>
);
}
Next.js / crawlability notes:
- Use
next/link with real hrefs โ emit <a href> so the link graph is crawlable.
- Do not gate cluster links behind
onClick, JS-only routers, or hover menus; those
don't pass topical signal reliably.
- Reflect hierarchy in URLs (
/guide/topic, /guide/topic-how-to) and add
BreadcrumbList JSON-LD (see the schema skill) so Google reads the structure.
- For multilingual sites (next-intl), keep the cluster graph within one locale and
set
hreflang/alternates per page โ never cross-link spokes across locales.
- Sitemap: generate
app/sitemap.ts from the same cluster data so every spoke is
discoverable and nothing is orphaned.
import type { MetadataRoute } from 'next';
import { pillar, spokes } from '@/content/clusters/topic';
export default function sitemap(): MetadataRoute.Sitemap {
const base = process.env.NEXT_PUBLIC_SITE_URL!;
return [
{ url: `${base}/guide/${pillar.slug}`, priority: 0.9 },
...spokes.map((s) => ({ url: `${base}/guide/${s.slug}`, priority: 0.7 })),
];
}
Step 7 โ Content-gap analysis (filling the topic)
A topic is "covered" when every subtopic a competitor ranks for has a home in your
cluster. To find gaps:
- List the top 3-5 ranking domains for your pillar head term.
- For each, enumerate the pages/sections they have that you don't (their nav,
internal links, and ranking long-tails).
- Map each missing subtopic to: an existing spoke (expand it), a new spoke, or "out
of scope" (decide deliberately, don't ignore).
- Re-run the SERP-overlap check (Step 2) before adding a spoke โ confirm it's a
separate page, not something that should merge into an existing one.
Prioritize gaps by: search demand ร intent value ร how cheaply you can win (low
competition, you already have adjacent authority).
Coverage scoring rubric (0-100)
Score a cluster's structural health. This is about architecture quality, not
rankings (rankings are the lagging outcome). Use it as a pre-publish gate and a
periodic audit.
| Dimension | Weight | Full marks when |
|---|
| Coverage | 25 | Every planned subtopic has a published page; no gaps vs top competitors |
| Internal linking | 20 | Every page โฅ 3 incoming links; pillarโspoke 100%; in-body anchors |
| No orphans | 15 | 0 pages unreachable; all spokes โค 2 clicks from pillar |
| No cannibalization | 15 | 0 duplicate primary keywords; no two pages competing for one query |
| Intent/template fit | 10 | Each page's template matches its dominant intent |
| Depth adequacy | 10 | Pillar 2500-4000w, spokes 1200-1800w, each fully answers its query |
| Anchor quality | 5 | Descriptive keyword anchors, no "click here", no over-optimization |
Bands: 90-100 authoritative cluster ยท 70-89 solid, minor gaps ยท 50-69
structurally weak (orphans/cannibalization likely) ยท <50 not a cluster yet.
Falsifiability โ how would we know this failed?
A cluster can score 100 structurally and still fail to build authority. Define the
failure test up front:
- Hypothesis: clustering + interlinking grows topical authority โ more of the
cluster ranks page 1 within 8-12 weeks.
- Leading indicators (weeks 1-4, before rankings move):
- Cluster pages get crawled and indexed (Search Console coverage).
- Internal-link impressions rise; spokes start getting impressions for their target
long-tails (even if position is low).
- Avg. crawl depth to spokes drops to โค 2.
- Lagging confirmation (weeks 8-12): pillar moves up for the head term; โฅ half the
spokes reach page 1 for their primary keyword; cannibalization flip-flops stop.
- It failed if: spokes stay un-indexed or have 0 impressions after 4 weeks
(discoverability/orphan problem) โ re-check the link matrix and sitemap; OR
impressions exist but positions don't improve over 12 weeks (content-quality or
intent-mismatch problem, not architecture) โ revisit template/depth, not links.
This split matters: a discoverability failure is fixed by links, a ranking failure
is fixed by content. Misdiagnosing wastes a quarter.
Pre-delivery checklist
Common mistakes
- Clustering by text similarity instead of SERP overlap โ pages that don't actually
belong together, or merges that should stay split.
- Pillar trying to rank for spoke long-tails โ self-cannibalization.
- Links only in nav/footer โ weak topical signal; put them in the body.
- Orphan spokes published but never linked โ un-indexed, invisible.
- Padding pillar to 4000 words instead of linking depth out to spokes.
- Cross-linking spokes across locales on a multilingual site โ dilutes per-locale graph.
Parts adapted from claude-seo (MIT, ยฉ 2026 agricidaniel).