
No Outlinks Audit
- 76 installs
- 93 repo stars
- Updated June 28, 2026
- infrasity-labs/dev-gtm-claude-skills
Find blog and content pages that link to nothing else on your domain and get three concrete internal-link fixes per dead-end with a filterable HTML report.
About
No Outlinks Audit is an agent skill for developer marketing and content sites that suffer silent structural SEO debt: pages that absorb internal PageRank but never pass it onward. Solo builders publishing docs, changelog posts, or GTM blogs on Next.js, static generators, or WordPress can invoke the skill to crawl content URLs with the correct fetch strategy per framework, classify which URLs are true dead-ends under the skill’s link rules, group them by topic, and receive actionable outgoing link recommendations rather than a generic “add more links” note. Each dead-end gets three specific targets with anchor copy and placement guidance ready to edit into the post. The deliverable is a styled HTML report teams can filter and hand to writers or drop into a client engagement. Use it after publishing bursts or site migrations when the internal graph may have grown unevenly. It complements orphan-page skills by fixing the opposite failure mode. Intermediate complexity because you must understand your CMS, base URL, and what counts as an internal link on your stack.
- Detects dead-end pages with zero qualifying same-domain outgoing internal links
- Framework-aware fetch: RSC payload for Next.js, standard curl for static and WordPress
- Structural inverse of orphan-page audits—incoming vs outgoing link gaps
- 3 targeted suggestions per page: anchor text, placement, and paste-ready context copy
- Topic clustering across dead-ends plus styled, filterable HTML report output
No Outlinks Audit by the numbers
- 76 all-time installs (skills.sh)
- +4 installs in the week ending Jul 25, 2026 (Skillselion tracking)
- Ranked #1,198 of 1,881 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Jul 26, 2026 (Skillselion catalog sync)
npx skills add https://github.com/infrasity-labs/dev-gtm-claude-skills --skill no-outlinks-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 76 |
|---|---|
| repo stars | ★ 93 |
| Last updated | June 28, 2026 |
| Repository | infrasity-labs/dev-gtm-claude-skills ↗ |
What it does
Find blog and content pages that link to nothing else on your domain and get three concrete internal-link fixes per dead-end with a filterable HTML report.
Files
SKILL: Dead-End Pages — No Outgoing Internal Links Audit
DESCRIPTION
Finds every blog/content page on a site that has zero outgoing links to any URL on the same domain in its body content. These pages absorb link equity but pass none on — they are structural dead-ends that silently suppress topical authority and hurt crawl efficiency across the site.
For each dead-end page the skill generates 3 suggestions: pages on the same site that the dead-end page SHOULD link out to, including the exact anchor text (the target page's top keyword), where in the dead-end page to place the link, and a ready-to-paste context copy sentence with the link already embedded.
This skill is the structural inverse of the Orphan Pages skill:
- Orphan Pages: pages with no incoming internal links (nobody links TO them)
- Dead-End Pages: pages with no outgoing internal links (they link out to nobody)
---
TOOLS
- Step 1 uses curl via bash_tool for sitemap discovery
- Step 2 uses curl via bash_tool for framework detection
- Step 3 uses curl via bash_tool for outgoing link detection (method varies by framework)
- Step 5 uses Ahrefs MCP (
site-explorer-top-pages) for keyword research
---
WORKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 1 — DISCOVER ALL PAGES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WHY THIS STEP EXISTS You need a complete list of all blog/content pages to check. Curl-based sitemap discovery is fast, free, and reflects the live site — not a stale crawl cache.
SUBSTEP 1A — FIND THE SITEMAP
Run in order until one returns valid XML:
curl -sL --max-time 15 "https://www.DOMAIN.com/sitemap.xml" | head -50
curl -sL --max-time 15 "https://www.DOMAIN.com/sitemap_index.xml" | head -50
curl -sL --max-time 15 "https://www.DOMAIN.com/robots.txt" | grep -i sitemapIf none work, crawl the blog index directly:
curl -sL "https://www.DOMAIN.com/blog/" | grep -oP 'href="[^"]*"' | grep '/blog/'SUBSTEP 1B — EXTRACT ALL CONTENT URLS
curl -sL "https://www.DOMAIN.com/sitemap.xml" \
| grep -oP '(?<=<loc>)[^<]+' \
| grep '/blog/' \
| grep -v '/blog/$' \
| grep -v '/page/' \
| sort -u > /tmp/blog_urls.txt
wc -l /tmp/blog_urls.txtCall this LIST_ALL. Record TOTAL_PAGES = length of LIST_ALL.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 2 — DETECT SITE FRAMEWORK ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WHY THIS STEP EXISTS The detection method for outgoing links depends entirely on how the site renders HTML. Running the wrong method gives completely wrong results — this step prevents that.
Run both checks:
# Check response headers
curl -sI "https://www.DOMAIN.com" | grep -i "x-powered-by\|x-nextjs\|x-vercel\|server\|generator"
# Check HTML source for framework signals
curl -sL "https://www.DOMAIN.com" \
| grep -o 'name="generator"[^>]*\|__NEXT_DATA__\|_next\|__nuxt\|ng-version\|gatsby'DECISION TABLE:
| Signal found | Framework | Method |
|---|---|---|
| x-nextjs-prerender in headers | Next.js | Step 3A |
| _next or __NEXT_DATA__ in HTML | Next.js | Step 3A |
| x-vercel-cache in headers + _next | Next.js/Vercel | Step 3A |
| gatsby in HTML source | Gatsby (SSG) | Step 3B |
| name="generator" content="WordPress" | WordPress | Step 3B |
| x-powered-by: PHP | Traditional CMS | Step 3B |
| Static HTML (<a> tags present) | Static/SSG | Step 3B |
| Zero <a> tags in curl response | Unknown SPA | Step 3C |
| __nuxt in HTML | Nuxt.js | Step 3C |
IMPORTANT: Always test the detection method on 3 sample pages before running the full list. Verify that a page you know has links returns links, and a page you know is sparse returns few/none. If results look wrong, re-read this step.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 3 — DETECT OUTGOING INTERNAL LINKS PER PAGE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Run the method determined in Step 2.
──────────────────────────────────────── STEP 3A — NEXT.JS SITES (RSC HEADER METHOD) ────────────────────────────────────────
HOW IT WORKS Next.js with React Server Components exposes a server-rendered payload endpoint. By sending RSC: 1 as a request header, the server returns the full rendered page data as a serialised React component tree — including all links — without needing a headless browser. This works on all Next.js sites using the App Router (Next.js 13+).
This is the correct method for any site running on Next.js / Vercel, regardless of whether it uses ISR, SSG, or SSR. The RSC payload always contains the rendered content with all internal links visible as plain text paths.
HOW TO VALIDATE THE FETCH WORKED A valid RSC response:
- Is always larger than 10KB (most blog posts return 50KB–250KB)
- Starts with React markers like
$Sreact.fragmentorJ:or1:"$ - If a page returns less than 10KB or has no
$in the first 500 chars,
the fetch failed — flag that page for manual review, do NOT mark it as a dead-end
import subprocess, re, time
domain = "DOMAIN.com" # without www, e.g. "infrasity.com"
blog_prefix = "/blog/" # content prefix, e.g. "/blog/"
with open('/tmp/blog_urls.txt') as f:
urls = [u.strip() for u in f if u.strip()]
# Asset extensions to exclude — these are not outgoing content links
SKIP_EXT = (
'.png','.webp','.jpg','.jpeg','.gif','.svg','.ico',
'.woff2','.woff','.ttf','.eot','.otf',
'.css','.js','.jsx','.ts','.tsx',
'.xml','.txt','.pdf','.zip','.mp4','.mp3','.webm'
)
# Path prefixes to exclude — assets, CDN, internal Next.js paths
SKIP_PREFIX = (
'/PostImages/', '/images/', '/fonts/', '/favicon',
'/_next/', '/_vercel/', '/static/',
'/api/', '//cdn.', '//assets.'
)
def get_outgoing_links(url):
result = subprocess.run([
'curl', '-sL', '--max-time', '10',
'-H', 'RSC: 1',
'-H', 'Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D',
url
], capture_output=True, text=True, timeout=15)
data = result.stdout
# Validate RSC fetch succeeded
if len(data) < 10000 or not any(m in data[:500] for m in ['$S', 'J:', '1:"']):
return None # Fetch failed — flag for manual review
# Extract all paths pointing to the same domain
# Extract both absolute domain links and relative paths starting with /
# (excluding protocol-relative links starting with //)
raw = re.findall(
rf'(?:(?:https?://)?(?:www\\.)?{re.escape(domain)}|(?<!/))/(?!/)([^\\s\"\\\'\\\\),\\]]+)',
data
)
raw = ['/' + r for r in raw]
clean = set()
for l in raw:
# Normalise: strip trailing junk
l = l.split('\\')[0].split('"')[0].split("'")[0].rstrip('),.]\\/')
if len(l) < 2:
continue
# Exclude assets
if any(l.lower().endswith(e) for e in SKIP_EXT):
continue
if any(l.startswith(p) for p in SKIP_PREFIX):
continue
# Exclude self-link
slug = url.rstrip('/').split('/')[-1]
if l.rstrip('/') == blog_prefix.rstrip('/') + '/' + slug:
continue
clean.add(l)
return clean
dead_ends = []
has_outlinks = []
failed = []
for i, url in enumerate(urls):
slug = url.split('/')[-1]
try:
links = get_outgoing_links(url)
if links is None:
failed.append(url)
print(f"[{i+1:3d}] FAILED {slug}")
elif len(links) == 0:
dead_ends.append(url)
print(f"[{i+1:3d}] DEAD-END {slug}")
else:
has_outlinks.append(url)
print(f"[{i+1:3d}] LINKS({len(links):2d}) {slug}")
except Exception as e:
failed.append(url)
print(f"[{i+1:3d}] ERROR {slug}: {e}")
time.sleep(0.15)
with open('/tmp/dead_end_urls.txt', 'w') as f:
f.write('\n'.join(dead_ends))
print(f"\nTotal: {len(urls)}")
print(f"Dead-ends: {len(dead_ends)}")
print(f"Has outlinks: {len(has_outlinks)}")
print(f"Failed: {len(failed)} (verify manually)")
print(f"Dead-end rate: {round(len(dead_ends)/len(urls)*100)}%")──────────────────────────────────────── STEP 3B — STATIC / WORDPRESS / TRADITIONAL CMS (STANDARD CURL) ────────────────────────────────────────
HOW IT WORKS For sites that serve fully-rendered HTML directly (static files, WordPress, Gatsby, traditional CMS), standard curl fetches the complete page including all links. Parse only the main content area to exclude nav/footer/sidebar links.
import subprocess, re, time
from urllib.parse import urljoin
domain = "DOMAIN.com"
base = "https://www." + domain
with open('/tmp/blog_urls.txt') as f:
urls = [u.strip() for u in f if u.strip()]
SKIP_EXT = (
'.png','.webp','.jpg','.jpeg','.gif','.svg','.ico',
'.woff2','.css','.js','.xml','.pdf','.zip'
)
def get_outgoing_links(url):
result = subprocess.run([
'curl', '-sL', '--max-time', '10',
'-A', 'Mozilla/5.0 (compatible; InternalLinkBot/1.0)',
url
], capture_output=True, text=True, timeout=15)
html = result.stdout
# Extract main content area only
content = ''
for tag in ['article', 'main', 'body']:
m = re.search(rf'<{tag}[^>]*>(.*?)</{tag}>', html, re.DOTALL | re.IGNORECASE)
if m:
content = m.group(1)
break
if not content:
content = html
hrefs = re.findall(r'<a\\s+[^>]*href\\s*=\\s*["\']([^"\'\\ ]+)["\']', content, re.IGNORECASE)
clean = set()
for href in hrefs:
href = href.strip()
if not href or href.startswith('#') or href.startswith('mailto:') or href.startswith('tel:'):
continue
abs_url = urljoin(base, href)
if domain not in abs_url:
continue
path = '/' + abs_url.split(domain, 1)[-1].lstrip('/')
if any(path.lower().endswith(e) for e in SKIP_EXT):
continue
if abs_url.rstrip('/') == url.rstrip('/'):
continue
clean.add(path)
return clean
dead_ends = []
has_outlinks = []
for i, url in enumerate(urls):
slug = url.split('/')[-1]
links = get_outgoing_links(url)
if links:
has_outlinks.append(url)
print(f"[{i+1:3d}] LINKS({len(links):2d}) {slug}")
else:
dead_ends.append(url)
print(f"[{i+1:3d}] DEAD-END {slug}")
time.sleep(0.2)
with open('/tmp/dead_end_urls.txt', 'w') as f:
f.write('\n'.join(dead_ends))
print(f"\nTotal: {len(urls)}")
print(f"Dead-ends: {len(dead_ends)}")
print(f"Has outlinks: {len(has_outlinks)}")
print(f"Dead-end rate: {round(len(dead_ends)/len(urls)*100)}%")──────────────────────────────────────── STEP 3C — UNKNOWN / PURE SPA — FLAG TO USER ────────────────────────────────────────
If framework detection is inconclusive (e.g. Nuxt.js, Angular, Vue SPA) or if curl returns fewer than 10 <a> tags across 3 sample pages, stop and tell the user:
"This site appears to use client-side rendering that cannot be detected through standard curl requests. To run this audit accurately, one of the following is needed:
1. Set up an Ahrefs Site Audit project for this domain (uses a headless browser that renders JS). Share the project ID once the first crawl completes. 2. Export a crawl from Screaming Frog with JavaScript rendering enabled and provide the CSV of internal links. 3. Confirm the exact framework being used so the right detection header can be applied.
Do not proceed to generate a report until the detection method is confirmed working on at least 5 sample pages."
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 4 — VALIDATE RESULTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WHY THIS STEP EXISTS Detection bugs are silent. A misconfigured asset filter or a failed fetch can silently produce a wrong dead-end list. This step catches that before suggestions are generated.
Do ALL of the following before proceeding:
1. Pick 3 random pages from LIST_DEAD_ENDS. Open each in a browser and manually confirm the page genuinely has no outgoing links to the domain. If any DO have links, re-check the detection script's asset filter and domain pattern.
2. Pick 3 pages from LIST_HAS_OUTLINKS. Verify they have visible links in the browser to other pages on the same domain.
3. If DEAD_END_RATE > 80%, something is likely wrong — the fetch may be returning only asset links or the domain pattern may not be matching. Debug before continuing.
4. If DEAD_END_RATE is 0%, the filter may be too loose (counting assets or self-links). Check what links are being detected on known-sparse pages.
5. For Step 3A (Next.js): if the failed list is non-empty, manually check those pages. Some may genuinely be dead-ends; others may have errored. Re-run failed pages individually before finalising the list.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 5 — KEYWORD RESEARCH ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WHY THIS STEP EXISTS To generate relevant outgoing link suggestions you need to know what each dead-end page AND each potential target page is about. One Ahrefs call covers the full blog.
TOOL: Ahrefs:site-explorer-top-pages
PARAMETERS:
target: blog prefix (e.g.www.DOMAIN.com/blog/)mode:prefixdate: today's date YYYY-MM-DDselect:url,top_keyword,sum_trafficlimit: 500order_by:sum_traffic:desc
RESULT: MAP_KEYWORDS — a keyword and traffic map for all pages.
For any dead-end page not in MAP_KEYWORDS, derive keyword from URL slug: replace hyphens with spaces. NEVER fabricate traffic numbers — use null.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 6 — CLUSTER AND GENERATE SUGGESTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WHY THIS STEP EXISTS Outgoing link suggestions must be topically relevant to the dead-end page. Clustering first, then matching within and across clusters, produces natural and useful suggestions.
HOW TO CLUSTER Group all dead-end pages by primary topic using their keyword and title. Create 5–15 clusters depending on the site's content breadth. Adapt cluster names to the actual content — do not use generic names like "Misc" or "Other" unless truly necessary.
HOW TO GENERATE SUGGESTIONS For each dead-end page, identify 3 pages from LIST_ALL (any page on the site, not only other dead-ends) that:
1. Are topically related — same or adjacent subject matter 2. Would be a natural next step for a reader of the dead-end page 3. Are ideally already well-linked pages — so the dead-end connects into the broader internal link graph
For each of the 3 suggestions, provide:
ANCHOR TEXT The top keyword of the TARGET page (the page being linked TO). Rules:
- Use the
kwfield from MAP_KEYWORDS for the target page - If target has no keyword data, derive from its URL slug
- Must read naturally as hyperlinked text in a sentence
- No em dashes (—) in or around the anchor text
WHERE TO PLACE A specific section-level description of where in the DEAD-END page to insert the outgoing link. Rules:
- Section-level specific, not just "somewhere in the article"
- Contextually logical — where a reader would naturally want to go deeper
- Examples:
"In the intro when defining the core concept" "After the comparison table when suggesting next steps" "In the Tools and Resources section at the end" "When first mentioning [adjacent topic] mid-article"
SUGGESTED CONTEXT COPY A ready-to-paste sentence (1–2 sentences max) to drop directly into the dead-end page at the placement location. Rules:
- Must contain the anchor text as a real HTML hyperlink to the TARGET page URL:
<a href="[target_url]">[target_kw]</a>
- Must read naturally in the context described in WHERE TO PLACE
- Must add genuine value — not just "click here to learn more"
- Should feel written for the dead-end page's specific voice and section
- 15–35 words total (excluding HTML tags)
- No em dashes (—) anywhere in the copy. Replace any em dash with a comma
or restructure the sentence entirely
- Examples:
"If you are just getting started, a clear overview of <a href="...">developer marketing strategy</a> will give you the framework to make these tactics work." "Teams that combine this with a solid <a href="...">b2b content marketing strategy</a> see compounding returns across both organic traffic and pipeline."
Store this as the copy field in each suggestion object in D[].
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STEP 7 — GENERATE HTML REPORT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
BEFORE WRITING ANY CODE read: → references/report-style-reference.html
The reference file is the single source of truth for all CSS variables, typography, component styles, spacing values, and JavaScript patterns. Do not invent new styles. Do not substitute different values. Copy them exactly from the reference.
REPORT HEADER VALUES
Site URL: https://www.DOMAIN.com/blog/
Total Blogs: TOTAL_PAGES
No Outlinks: DEAD_END_COUNT ← red stat (fail)
Have Outlinks: PAGES_WITH_OUTLINKS ← green stat (pass)
Dead-End Rate: DEAD_END_RATE% ← amber stat (warn)
Suggestions: DEAD_END_COUNT × 3 ← indigo stat (accent)Fix-tag (choose based on method used):
- Next.js:
⚑ RSC payload detection (Next.js) · YYYY-MM-DD - Static/WP:
⚑ Curl HTML parse · YYYY-MM-DD - Site Audit:
⚑ Ahrefs Site Audit (project ID: XXXX) · YYYY-MM-DD
REPORT FOOTER: DOMAIN.com · Dead-End Pages Audit · YYYY-MM-DD / Source: [detection method]
SUGGESTION CARD LABELS (different from orphan skill)
- "Link To" — the target page (page to link out TO)
- "Anchor Text" — the target page's top keyword
- "Where to Place" — section in the dead-end page
- "Context Copy" — ready-to-paste sentence with link embedded
DATA STRUCTURE — D[] array
One object per dead-end page:
{
url: "https://www.DOMAIN.com/blog/page-slug", // dead-end page URL
title: "Page Title", // display title
kw: "top keyword", // dead-end page's own keyword
tr: 123, // monthly traffic or null
cluster: "Cluster Name", // must match a key in CC{}
s: [
{
from: "Target Page Title", // page to link OUT TO
fromUrl: "https://www.DOMAIN.com/blog/target", // target URL
anchor: "target page keyword", // target's top keyword
place: "Where in the dead-end page to add the link",
copy: "Sentence with <a href=\"...\">target keyword</a> embedded."
},
// × 3 per dead-end page
]
}NOTE ON FIELD SEMANTICS from and fromUrl refer to the TARGET page (the page being linked TO), not the source page. This is the opposite direction from the orphan skill. The JS card builder must label these as "Link To" not "Source Page":
// In the suggestion card builder:
`<div class="sug-from-label">Link To</div>
<div class="sug-from-title">${s.from}</div>
<a class="sug-from-url" href="${s.fromUrl}" target="_blank">...</a>`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ STRICT RULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. READ references/report-style-reference.html BEFORE writing any HTML or CSS. Mandatory — no exceptions.
2. ALWAYS run Step 2 (framework detection) before any link detection. Never assume the framework based on the domain name or a visual inspection of the site.
3. For Next.js sites, ALWAYS use the RSC header method (Step 3A). Never use standard curl on a Next.js site — it returns an empty JS shell and produces wrong results.
4. ALWAYS validate RSC fetch success per page. A response under 10KB or missing React markers means the fetch failed. Flag those pages, never silently mark them as dead-ends.
5. NEVER count asset links (images, fonts, scripts, stylesheets) as qualifying outgoing links. A page that only links to /logo.png and /styles.css is a dead-end.
6. NEVER count self-links (the page linking to itself) as a qualifying outgoing link.
7. ALWAYS validate results on sample pages (Step 4) before generating suggestions. If the dead-end rate is above 80% or exactly 0%, something is wrong — debug first.
8. ANCHOR TEXT = top keyword of the TARGET page, not the dead-end page.
9. NEVER use em dashes (—) in SUGGESTED CONTEXT COPY. Replace with a comma or restructure the sentence.
10. ALWAYS produce exactly 3 suggestions per dead-end page. No more, no fewer.
11. ALWAYS make the dead-end page URL and all 3 target page URLs clickable <a href> links opening in target="_blank".
12. NEVER deviate from the CSS values in report-style-reference.html.
13. ALWAYS build the report data-driven using D[] and CC[]. Never write static HTML for individual blog cards.
14. ALWAYS clean LIST_DEAD_ENDS before generating suggestions. Remove non-200 pages, pagination, tag/category pages, and any URL that is not a standalone post.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ EDGE CASES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NEXT.JS APP ROUTER vs PAGES ROUTER RSC header works on App Router (Next.js 13+). For older Pages Router sites, the RSC payload may not be available. Test signal: if response is under 10KB consistently across multiple pages, the site may be using Pages Router. Fall back to checking for __NEXT_DATA__ in the standard HTML response — this contains the serialised page props and may include link data.
SITE USES A CDN THAT STRIPS HEADERS Some CDN configurations strip custom request headers before they reach the origin. If RSC responses consistently return under 10KB, test by adding a cache-busting query param: ?_rsc=1 appended to the URL. If that also fails, the CDN is likely stripping the RSC header — fall back to the __NEXT_DATA__ approach.
LARGE SITES (200+ PAGES) For sites with more than 200 blog pages, run the detection in batches of 50 with a 0.3s delay between requests to avoid rate limiting. Prioritise the top-traffic pages from MAP_KEYWORDS first. If time is a constraint, audit the top 100 pages by traffic and note in the report that the audit covers the top 100.
ALL PAGES ARE DEAD-ENDS (RATE = 100%) This means the detection failed entirely — either the domain pattern is wrong or the asset filter is too aggressive. Run the debug check: print the raw links found on 3 known-linked pages. If nothing appears, fix the regex. If only assets appear, widen the SKIP_EXT/SKIP_PREFIX rules.
DEAD-END RATE IS 0% Every page appears to have outgoing links. The filter may be too loose — possibly counting nav or footer links. Add is_content filtering by restricting the link search to only the <article> or <main> tag content area.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ COMPUTING FINAL METRICS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TOTAL_PAGES = len(LIST_ALL) DEAD_END_COUNT = len(LIST_DEAD_ENDS) PAGES_WITH_OUTLINKS = TOTAL_PAGES - DEAD_END_COUNT DEAD_END_RATE = round(DEAD_END_COUNT / TOTAL_PAGES * 100) TOTAL_SUGGESTIONS = DEAD_END_COUNT × 3
No Outlinks Audit: Dead-End Pages
Finds every blog or content page that has zero outgoing links to any other page on the same domain, then generates 3 targeted outgoing link suggestions per dead-end, with anchor text, placement guidance, and ready-to-paste context copy. Outputs a styled, filterable HTML report.
---
What this skill does
A dead-end page is a page that links out to no other page on the same domain. It absorbs link equity but passes none on, a structural gap that suppresses topical authority and hurts crawl efficiency across the site.
This skill detects dead-end pages by fetching each content page using the correct method for the site's framework (RSC payload for Next.js, standard curl for static and WordPress sites), identifies which have zero qualifying outgoing internal links, clusters them by topic, and generates 3 specific suggestions per page, with the exact anchor text to use, where in the dead-end page to place it, and a sentence ready to drop in.
This is the structural inverse of the Orphan Pages skill:
- Orphan Pages: pages with no incoming internal links (nobody links TO them)
- No Outlinks (this skill): pages with no outgoing internal links (they link out to nobody)
Built for:
- SEO and content teams finding content that is silently breaking the internal link graph
- Developer marketing auditing a growing blog for structural link gaps
- Agencies delivering a dead-end page audit as part of a technical SEO engagement
---
Installation
Claude Code (Recommended)
Clone the repo. The skill activates automatically when you open it in Claude Code:
git clone https://github.com/Infrasity-Labs/dev-gtm-claude-skills.git
cd dev-gtm-claude-skills
claudeThen trigger it with:
/dev-gtm no-outlinks example.com/blog/Or describe what you want. Claude activates the skill when you provide a domain and ask for a dead-end page audit or outgoing internal links report.
Claude Web (Free / Pro)
1. Go to [Settings → Capabilities](https://claude.ai/settings/capabilities) and enable Code execution and file creation 2. Go to [Customize → Skills](https://claude.ai/customize/skills) 3. Click + → Create skill → Upload a skill 4. Zip this skill folder and upload it:
cd dev-gtm-claude-skills/skills/no-outlinks-audit zip -r ../no-outlinks-audit.zip . zip -r no-outlinks-audit.zip no-outlinks-audit/
Upload `no-outlinks-audit.zip` and toggle it on.
---
## Ahrefs Setup (Required)
This skill uses Ahrefs to fetch keyword and traffic data for every page, which powers topically accurate linking suggestions. An active Ahrefs account with API access is required.
### Claude Code
Open `.claude/settings.json` in the cloned repo and add your Ahrefs MCP credentials:
{ "mcpServers": { "Ahrefs": { "command": "npx", "args": ["-y", "@ahrefs/mcp-server"], "env": { "AHREFS_API_KEY": "your-ahrefs-api-key" } } } }
### Claude Web (Free / Pro)
Go to **[Settings → Integrations](https://claude.ai/settings/integrations)**, find **Ahrefs**, and connect your account.
---
## How to use
Find pages with no outgoing internal links on example.com
Which blogs on example.com don't link out to anything?
Run a dead-end page audit for example.com/blog/
/dev-gtm no-outlinks example.com/blog/
---
## Inputs
| Field | Required | Notes |
|-------|----------|-------|
| Domain or URL | ✅ | Domain (`example.com`), full URL, or blog prefix URL |
---
## Output
A self-contained filterable HTML report saved to `/mnt/user-data/outputs/[domain]-dead-end-pages-audit.html` and presented with a download link.
The report contains:
- **Header stats**: Total Blogs, No Outlinks count, Have Outlinks count, Dead-End Rate %, Total Suggestions
- **Detection method fix-tag**: records whether RSC payload, standard curl, or Ahrefs Site Audit was used
- **Cluster sidebar**: topic clusters built automatically from dead-end pages, clickable for filtering
- **Search and filter bar**: live search across all page titles and keywords
- **Per-page cards**: title, URL, top keyword, monthly traffic, cluster tag
- **3 suggestions per dead-end**: Target page to link out to, anchor text (the target page's top keyword), where in the dead-end page to place the link, and a ready-to-paste sentence with the link already embedded
---
## Things to know
**Framework detection runs before any link detection.** The skill checks response headers and HTML source signals to determine whether the site is Next.js, WordPress, Gatsby, or something else before fetching a single page. Running the wrong method on a Next.js site produces an empty shell, with no links found anywhere, even on pages that have many.
**Asset links do not count.** A page that only links to `/logo.png`, `/styles.css`, or `/_next/static/` is still classified as a dead-end. Only links to other content pages on the same domain qualify.
**Self-links do not count.** A page linking to itself is not an outgoing link.
**Dead-end rate above 80% means something is wrong.** If more than 80% of pages are flagged as dead-ends, the detection is almost certainly misconfigured. The skill validates results on 3 sample pages before generating any suggestions and stops if the rate looks wrong.
**Pure SPAs (Nuxt, Angular, Vue) cannot be crawled by curl.** For JS-rendered sites that curl cannot read, the skill stops and explains the three options: Ahrefs Site Audit, Screaming Frog with JS rendering, or confirming the exact framework so the right detection header can be applied.
---
## How it works
1. **Discover content pages**: finds the sitemap via `curl` (tries `/sitemap.xml`, `/sitemap_index.xml`, `robots.txt` in order), extracts all URLs, filters to the content prefix, strips pagination and archive pages
2. **Detect the site framework**: checks response headers (`x-nextjs-prerender`, `x-vercel-cache`) and HTML source (`__NEXT_DATA__`, `_next`, `gatsby`, `generator` meta tag) to select the correct detection method
3. **Detect outgoing links per page**: for Next.js, sends `curl` with `RSC: 1` header to get the React Server Components payload, which contains all rendered links; for static/WordPress: fetches standard HTML and parses the `<article>` or `<main>` content area only
4. **Validate results**: manually confirms 3 sample dead-ends and 3 sample pages-with-links match reality before proceeding; flags and stops if dead-end rate is above 80% or exactly 0%
5. **Fetch keyword data**: calls `Ahrefs:site-explorer-top-pages` for the full content prefix to get the top keyword and traffic for every page in a single call
6. **Cluster by topic**: groups dead-end pages into 5–15 topic clusters adapted to the site's actual content
7. **Generate suggestions**: for each dead-end page, identifies 3 topically related target pages from the full URL list and writes anchor text (the target's top keyword), placement guidance, and a ready-to-paste context copy sentence per suggestion
8. **Generate the HTML report**: reads `references/report-style-reference.html` and builds a fully data-driven report by populating `CC{}` (cluster colour map) and `D[]` (dead-end data); the sidebar builder and `filterAll()` function render all cards from this data
---
## Config schema reference
The JavaScript data objects injected into the HTML report:
// Cluster colour map const CC = { "Developer Marketing": "c-devmkt", "SEO": "c-seo", "Content Marketing": "c-content", "Documentation": "c-docs", "GTM Strategy": "c-gtm" };
// Dead-end page data: one object per dead-end page const D = [ { url: "https://www.example.com/blog/api-authentication", title: "API Authentication Best Practices", kw: "api authentication", tr: 340, cluster: "Documentation", s: [ { to: "OAuth 2.0 Implementation Guide", // target page to link OUT TO toUrl: "https://www.example.com/blog/oauth2-guide", // target page URL anchor: "oauth 2.0 implementation", // target page's top keyword place: "After the section on token storage, when recommending secure auth flows", copy: "For teams implementing token-based auth, a detailed look at <a href=\"https://www.example.com/blog/oauth2-guide\">oauth 2.0 implementation</a> covers the handshake flow and scoping patterns that prevent over-permissioning." } // × 3 per dead-end page ] } ];
> **Note on field direction:** to and toUrl refer to the **target page** (the page being linked TO from the dead-end page), not the dead-end page itself. The suggestion card labels this "Link To", not "Source Page".
---
## File structure
no-outlinks-audit/ ├── SKILL.md # Skill instructions Claude follows ├── README.md # This file └── references/ └── report-style-reference.html # Canonical visual and code reference for the HTML report
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dead-End Pages Audit, Design Reference</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<!--
══════════════════════════════════════════════════════════════════════
DESIGN REFERENCE, Dead-End Pages / No Outgoing Internal Links Audit Report
══════════════════════════════════════════════════════════════════════
This file is the single source of truth for every report generated
by the no-outlinks-audit skill.
HOW TO USE THIS FILE
────────────────────
1. Copy CSS variables from :root, never substitute different values.
2. Copy component HTML patterns from the annotated examples below.
3. Copy JavaScript patterns (filterAll, sidebar, toggle) verbatim.
4. Replace placeholder content with real audit data.
5. Do not add new CSS classes or modify existing values.
SECTIONS IN THIS FILE
─────────────────────
A. CSS Design Tokens (:root variables)
B. Full Stylesheet (all component styles)
C. Component Catalogue (annotated HTML examples of every component)
C1. Header
C2. Controls Bar
C3. Layout (sidebar + content)
C4. Sidebar Link
C5. Cluster Section Header
C6. Blog Card, Collapsed
C7. Blog Card, Expanded (Suggestions Panel)
C8. Cluster Tags
C9. Footer
C10. No-Results State
D. JavaScript Reference (filterAll, sidebar build, toggle)
══════════════════════════════════════════════════════════════════════
-->
<style>
/* ══════════════════════════════════════════════════════
A. DESIGN TOKENS, copy :root verbatim into every report
══════════════════════════════════════════════════════ */
:root {
--bg: #0a0a0b;
--surface: #111113;
--surface2: #18181c;
--tag-bg: #1e1e28;
--border: #27272e;
--border2: #35353f;
--text: #e8e8f0;
--text2: #9090a8;
--text3: #5a5a72;
--pass: #22c55e;
--pass-bg: #0d2b1a;
--pass-border: #14532d;
--warn: #f59e0b;
--warn-bg: #2b1f06;
--warn-border: #78350f;
--fail: #ef4444;
--fail-bg: #2b0a0a;
--fail-border: #7f1d1d;
--accent: #6366f1;
--accent2: #818cf8;
}
/* ══════════════════════════════════════════════════════
B. FULL STYLESHEET
══════════════════════════════════════════════════════ */
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'IBM Plex Sans',sans-serif;font-size:14px;line-height:1.6;min-height:100vh}
/* HEADER */
.header{border-bottom:1px solid var(--border);padding:48px 64px 40px;background:linear-gradient(180deg,#0f0f18 0%,var(--bg) 100%);position:relative;overflow:hidden}
.header::before{content:'';position:absolute;top:-60px;left:-60px;width:300px;height:300px;background:radial-gradient(circle,rgba(99,102,241,.12) 0%,transparent 70%);pointer-events:none}
.header-meta{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--accent2);letter-spacing:.12em;text-transform:uppercase;margin-bottom:12px}
.header h1{font-size:32px;font-weight:600;letter-spacing:-.02em;color:var(--text);margin-bottom:8px}
.header-url{font-family:'IBM Plex Mono',monospace;font-size:13px;color:var(--text2);background:var(--surface2);border:1px solid var(--border);display:inline-block;padding:6px 14px;border-radius:4px;margin-bottom:32px}
.header-stats{display:flex;gap:32px;flex-wrap:wrap}
.hstat{display:flex;flex-direction:column;gap:4px}
.hstat-val{font-family:'IBM Plex Mono',monospace;font-size:28px;font-weight:600;line-height:1}
.hstat-val.total{color:var(--text)}.hstat-val.fail{color:var(--fail)}.hstat-val.pass{color:var(--pass)}.hstat-val.warn{color:var(--warn)}.hstat-val.accent{color:var(--accent2)}
.hstat-label{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em}
.stat-divider{width:1px;background:var(--border);align-self:stretch;margin:4px 0}
.fix-tag{margin-top:20px;display:inline-flex;align-items:center;gap:8px;background:var(--warn-bg);border:1px solid var(--warn-border);padding:6px 14px;border-radius:4px;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--warn)}
/* CONTROLS BAR */
.controls{background:var(--surface);border-bottom:1px solid var(--border);padding:14px 64px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;position:sticky;top:0;z-index:100}
.search-wrap{position:relative;flex:1;min-width:220px}
.search-wrap svg{position:absolute;left:11px;top:50%;transform:translateY(-50%);opacity:.4}
input[type=text]{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);font-family:'IBM Plex Mono',monospace;font-size:12px;padding:8px 11px 8px 34px;border-radius:4px;outline:none;transition:border-color .15s}
input[type=text]:focus{border-color:var(--accent)}
input[type=text]::placeholder{color:var(--text3)}
select{background:var(--bg);border:1px solid var(--border);color:var(--text2);font-family:'IBM Plex Mono',monospace;font-size:12px;padding:8px 12px;border-radius:4px;outline:none;cursor:pointer}
.result-count{margin-left:auto;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--text3)}
.result-count strong{color:var(--fail)}
/* LAYOUT */
.main{display:grid;grid-template-columns:240px 1fr;min-height:calc(100vh - 220px)}
/* SIDEBAR */
.sidebar{border-right:1px solid var(--border);padding:24px 0;position:sticky;top:57px;height:calc(100vh - 57px);overflow-y:auto}
.sidebar::-webkit-scrollbar{width:4px}
.sidebar::-webkit-scrollbar-track{background:transparent}
.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.sidebar-heading{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.15em;padding:8px 20px 4px}
.sidebar-link{display:flex;align-items:center;justify-content:space-between;padding:7px 20px;color:var(--text2);text-decoration:none;font-size:12px;cursor:pointer;transition:all .15s;border-left:2px solid transparent}
.sidebar-link:hover{background:var(--surface2);color:var(--text);border-left-color:var(--border2)}
.sidebar-link.active{background:var(--surface2);color:var(--text);border-left-color:var(--accent)}
.sc{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--text3)}
/* CONTENT */
.content{padding:40px 56px;max-width:1200px}
/* CLUSTER SECTION */
.cluster-section{margin-bottom:64px;scroll-margin-top:70px}
.cluster-header{display:flex;align-items:center;gap:12px;margin-bottom:20px;padding-bottom:14px;border-bottom:1px solid var(--border)}
.cluster-title{font-size:18px;font-weight:600;letter-spacing:-.01em}
.cluster-badge{font-family:'IBM Plex Mono',monospace;font-size:11px;padding:3px 10px;border-radius:20px;border:1px solid var(--border2);color:var(--text3)}
.section-label{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--accent2);text-transform:uppercase;letter-spacing:.15em;margin-bottom:14px}
/* BLOG CARD */
.blog-card{border:1px solid var(--border);border-radius:6px;margin-bottom:10px;overflow:hidden;transition:border-color .15s}
.blog-card:hover{border-color:var(--border2)}
.blog-card.hidden{display:none}
.blog-header{display:flex;align-items:stretch;background:var(--surface);cursor:pointer;user-select:none}
.blog-header:hover{background:var(--surface2)}
.blog-title-col{padding:14px 18px;flex:1;min-width:0}
.blog-name{font-size:13px;font-weight:500;color:var(--text);margin-bottom:3px;line-height:1.35}
.blog-url{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--accent2);text-decoration:none;opacity:.8}
.blog-url:hover{opacity:1;text-decoration:underline}
.blog-kw-col{padding:14px 16px;border-left:1px solid var(--border);min-width:170px;display:flex;flex-direction:column;gap:4px;justify-content:center}
.kw-pill{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--text2);background:var(--tag-bg);border:1px solid var(--border2);padding:2px 8px;border-radius:3px;display:inline-block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:150px}
.kw-traffic{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--text3)}
.kw-traffic.hot{color:var(--pass)}
.blog-cluster-col{padding:14px;border-left:1px solid var(--border);display:flex;align-items:center;justify-content:center;min-width:130px}
.chevron{color:var(--text3);font-size:9px;transition:transform .2s;padding:14px;display:flex;align-items:center;border-left:1px solid var(--border)}
.blog-card.open .chevron{transform:rotate(180deg)}
/* SUGGESTIONS PANEL */
.blog-body{display:none;border-top:1px solid var(--border);background:var(--bg)}
.blog-card.open .blog-body{display:block}
.sug-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border)}
.sug-cell{background:var(--bg);padding:16px 18px}
.sug-num{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--text3);margin-bottom:8px;text-transform:uppercase;letter-spacing:.1em}
.sug-from-title{font-size:12px;font-weight:500;color:var(--text);margin-bottom:4px;line-height:1.35}
.sug-from-url{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--accent2);text-decoration:none;opacity:.75;display:inline-block;margin-bottom:10px;word-break:break-all}
.sug-from-url:hover{opacity:1;text-decoration:underline}
.sug-divider{height:1px;background:var(--border);margin:8px 0}
.sug-anchor-label{font-family:'IBM Plex Mono',monospace;font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px}
.sug-anchor{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--pass);background:var(--pass-bg);border:1px solid var(--pass-border);padding:3px 8px;border-radius:3px;display:inline-block;margin-bottom:10px}
.sug-place-label{font-family:'IBM Plex Mono',monospace;font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px}
.sug-place{font-size:12px;color:var(--text2);line-height:1.5}
.sug-copy-label{font-family:'IBM Plex Mono',monospace;font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-top:10px;margin-bottom:4px}
.sug-copy{font-size:12px;color:var(--text2);line-height:1.6;background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:8px 10px;word-break:break-word}
.sug-copy a{color:var(--accent2);text-decoration:underline;word-break:break-all}
/* CLUSTER TAGS */
.ctag{font-family:'IBM Plex Mono',monospace;font-size:10px;padding:3px 9px;border-radius:3px;border:1px solid;white-space:nowrap;letter-spacing:.03em}
.c-reddit {color:#fb923c;border-color:rgba(251,146,60,.3);background:rgba(251,146,60,.07)}
.c-devmkt {color:#60a5fa;border-color:rgba(96,165,250,.3);background:rgba(96,165,250,.07)}
.c-content {color:#a78bfa;border-color:rgba(167,139,250,.3);background:rgba(167,139,250,.07)}
.c-seo {color:#f472b6;border-color:rgba(244,114,182,.3);background:rgba(244,114,182,.07)}
.c-docs {color:#34d399;border-color:rgba(52,211,153,.3);background:rgba(52,211,153,.07)}
.c-llm {color:#fbbf24;border-color:rgba(251,191,36,.3);background:rgba(251,191,36,.07)}
.c-gtm {color:#fb7185;border-color:rgba(251,113,133,.3);background:rgba(251,113,133,.07)}
.c-devrel {color:#2dd4bf;border-color:rgba(45,212,191,.3);background:rgba(45,212,191,.07)}
.c-techwrite{color:#67e8f9;border-color:rgba(103,232,249,.3);background:rgba(103,232,249,.07)}
.c-product {color:#fda4af;border-color:rgba(253,164,175,.3);background:rgba(253,164,175,.07)}
.c-other {color:#94a3b8;border-color:rgba(148,163,184,.3);background:rgba(148,163,184,.07)}
/* NO RESULTS */
.no-results{text-align:center;padding:80px;color:var(--text3);font-family:'IBM Plex Mono',monospace;display:none}
.no-results strong{color:var(--text2);display:block;font-size:16px;margin-bottom:8px}
/* FOOTER */
footer{border-top:1px solid var(--border);padding:20px 64px;display:flex;justify-content:space-between;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--text3)}
/* RESPONSIVE */
@media(max-width:900px){
.main{grid-template-columns:1fr}
.sidebar{display:none}
.header,.controls,.content{padding-left:20px;padding-right:20px}
.sug-grid{grid-template-columns:1fr}
}
</style>
</head>
<body>
<!--
══════════════════════════════════════════════════════════════════════
C. COMPONENT CATALOGUE
Each section shows the exact HTML pattern to copy into a real report.
Replace all placeholder text/values with real audit data.
══════════════════════════════════════════════════════════════════════
-->
<!-- C1. HEADER ─────────────────────────────────────── -->
<div class="header">
<div class="header-meta">Internal Linking Audit Report · example.com</div>
<h1>Dead-End Blog Post Audit</h1>
<div class="header-url">https://www.example.com/blog/</div>
<div class="header-stats">
<!-- Stat order: Total · divider · Fail · Pass · divider · Warn · Accent -->
<div class="hstat"><span class="hstat-val total">136</span><span class="hstat-label">Total Blogs</span></div>
<div class="stat-divider"></div>
<div class="hstat"><span class="hstat-val fail">84</span><span class="hstat-label">No Outgoing Links</span></div>
<div class="hstat"><span class="hstat-val pass">52</span><span class="hstat-label">Have Outlinks</span></div>
<div class="stat-divider"></div>
<div class="hstat"><span class="hstat-val warn">62%</span><span class="hstat-label">Gap Rate</span></div>
<div class="hstat"><span class="hstat-val accent">252</span><span class="hstat-label">Suggestions</span></div>
</div>
<!-- Method/version note, always include below stats -->
<div class="fix-tag">⚑ RSC payload detection (Next.js) · YYYY-MM-DD</div>
</div>
<!-- C2. CONTROLS BAR ───────────────────────────────── -->
<div class="controls">
<div class="search-wrap">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Search title, URL or keyword…" oninput="filterAll()">
</div>
<select id="clusterFilter" onchange="filterAll()">
<option value="">All Clusters</option>
<option>Reddit Marketing</option>
<option>Developer Marketing</option>
<option>Content Marketing</option>
<option>SEO</option>
<option>Documentation</option>
<option>LLM / AI Visibility</option>
<option>GTM Strategy</option>
<option>DevRel</option>
<option>Technical Writing</option>
<option>Product</option>
<option>Other</option>
</select>
<span class="result-count">Showing <strong id="vc">1</strong> of 1</span>
</div>
<!-- C3. LAYOUT ─────────────────────────────────────── -->
<div class="main">
<!-- C4. SIDEBAR, shell; JS builds links inside -->
<nav class="sidebar" id="sidebar">
<!--
JS inserts:
<div class="sidebar-heading">Clusters</div>
<div class="sidebar-link active">
<span>All Clusters</span><span class="sc">84</span>
</div>
<div class="sidebar-link">
<span>Reddit Marketing</span><span class="sc">12</span>
</div>
… one per cluster
-->
</nav>
<div class="content" id="content">
<!-- C10. NO RESULTS STATE -->
<div class="no-results" id="noResults">
<strong>No results found</strong>
Try a different search or cluster filter
</div>
<!-- C5. CLUSTER SECTION HEADER
id = "sec-" + cluster name kebab-cased
e.g. "Reddit Marketing" → id="sec-Reddit-Marketing" -->
<div class="cluster-section" id="sec-Reddit-Marketing">
<div class="cluster-header">
<div class="cluster-title">Reddit Marketing</div>
<span class="cluster-badge">12 blogs</span>
</div>
<!-- C6. BLOG CARD, COLLAPSED
data-title, data-url, data-kw, data-cluster drive search/filter -->
<div class="blog-card"
data-title="karma farming vs credibility on reddit"
data-url="reddit-karma-farming-vs-credibility"
data-kw="reddit karma farming"
data-cluster="Reddit Marketing">
<div class="blog-header" onclick="this.closest('.blog-card').classList.toggle('open')">
<!-- Column 1: title + URL -->
<div class="blog-title-col">
<div class="blog-name">Karma Farming vs Credibility on Reddit</div>
<a class="blog-url"
href="https://www.example.com/blog/reddit-karma-farming-vs-credibility"
target="_blank"
onclick="event.stopPropagation()">
example.com/blog/reddit-karma-farming-vs-credibility
</a>
</div>
<!-- Column 2: keyword + traffic
Rules:
null → <span class="kw-traffic">, </span>
≥10 /mo → <span class="kw-traffic hot">▲ N visits/mo</span>
1-9 /mo → <span class="kw-traffic">▲ N visits/mo</span>
0 traffic → <span class="kw-traffic">ranking · 0 traffic</span>
-->
<div class="blog-kw-col">
<span class="kw-pill">reddit karma farming</span>
<span class="kw-traffic">, </span>
</div>
<!-- Column 3: cluster tag -->
<div class="blog-cluster-col">
<span class="ctag c-reddit">Reddit Marketing</span>
</div>
<!-- Column 4: chevron -->
<div class="chevron">▼</div>
</div>
<!-- C7. SUGGESTIONS PANEL, EXPANDED
Always exactly 3 .sug-cell columns.
Both target URL (blog-url) and all "link to" URLs (sug-from-url)
must be clickable <a href> links opening target="_blank". -->
<div class="blog-body">
<div class="sug-grid">
<div class="sug-cell">
<div class="sug-num">Suggestion 1</div>
<div style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px">Link To</div>
<div class="sug-from-title">Dos and Don'ts of Reddit Marketing</div>
<a class="sug-from-url" href="https://www.example.com/blog/reddit-marketing" target="_blank">example.com/blog/reddit-marketing</a>
<div class="sug-divider"></div>
<div class="sug-anchor-label">Anchor Text</div>
<div class="sug-anchor">"karma farming on Reddit"</div>
<div class="sug-place-label">Where to Place</div>
<div class="sug-place">In the 'What to Avoid' section when listing bad Reddit practices</div>
<div class="sug-copy-label">Context Copy</div>
<div class="sug-copy">When building Reddit presence, understand the risks of <a href="https://www.example.com/blog/reddit-karma-farming-vs-credibility">karma farming vs building real credibility</a>, the difference determines whether your account earns trust or gets flagged.</div>
</div>
<div class="sug-cell">
<div class="sug-num">Suggestion 2</div>
<div style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px">Link To</div>
<div class="sug-from-title">Best Subreddits to Join for B2B SaaS Startups</div>
<a class="sug-from-url" href="https://www.example.com/blog/best-subreddits" target="_blank">example.com/blog/best-subreddits</a>
<div class="sug-divider"></div>
<div class="sug-anchor-label">Anchor Text</div>
<div class="sug-anchor">"karma vs credibility debate"</div>
<div class="sug-place-label">Where to Place</div>
<div class="sug-place">In the intro when explaining why genuine participation matters</div>
</div>
<div class="sug-cell">
<div class="sug-num">Suggestion 3</div>
<div style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px">Link To</div>
<div class="sug-from-title">How Do You Get Karma on Reddit</div>
<a class="sug-from-url" href="https://www.example.com/blog/how-do-you-get-karma-on-reddit" target="_blank">example.com/blog/how-do-you-get-karma-on-reddit</a>
<div class="sug-divider"></div>
<div class="sug-anchor-label">Anchor Text</div>
<div class="sug-anchor">"karma farming vs building real credibility"</div>
<div class="sug-place-label">Where to Place</div>
<div class="sug-place">After the section on karma-earning tactics, as a cautionary contrast</div>
</div>
</div>
</div>
</div><!-- /.blog-card -->
<!-- C8. ALL CLUSTER TAG VARIANTS
Use .ctag base + one colour modifier.
Map cluster name → class via CC{} in JS. -->
<div style="padding:16px 0 4px;display:flex;flex-wrap:wrap;gap:8px;border-top:1px solid var(--border);margin-top:16px">
<div style="width:100%;font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:6px">C8 · All cluster tag variants</div>
<span class="ctag c-reddit">Reddit Marketing</span>
<span class="ctag c-devmkt">Developer Marketing</span>
<span class="ctag c-content">Content Marketing</span>
<span class="ctag c-seo">SEO</span>
<span class="ctag c-docs">Documentation</span>
<span class="ctag c-llm">LLM / AI Visibility</span>
<span class="ctag c-gtm">GTM Strategy</span>
<span class="ctag c-devrel">DevRel</span>
<span class="ctag c-techwrite">Technical Writing</span>
<span class="ctag c-product">Product</span>
<span class="ctag c-other">Other</span>
</div>
</div><!-- /.cluster-section -->
</div><!-- /#content -->
</div><!-- /.main -->
<!-- C9. FOOTER -->
<footer>
<span>example.com · Dead-End Pages Audit · YYYY-MM-DD</span>
<span>Source: RSC payload detection + Ahrefs top-pages</span>
</footer>
<!--
══════════════════════════════════════════════════════════════════════
D. JAVASCRIPT REFERENCE
Copy D1–D4 verbatim. Replace CC{} cluster map and D[] data array
with real audit data. Do not modify filterAll() logic.
══════════════════════════════════════════════════════════════════════
-->
<script>
// D1. CLUSTER → CSS CLASS MAP
// Add/remove entries to match actual clusters in the audit.
const CC = {
"Reddit Marketing": "c-reddit",
"Developer Marketing": "c-devmkt",
"Content Marketing": "c-content",
"SEO": "c-seo",
"Documentation": "c-docs",
"LLM / AI Visibility": "c-llm",
"GTM Strategy": "c-gtm",
"DevRel": "c-devrel",
"Technical Writing": "c-techwrite",
"Product": "c-product",
"Other": "c-other"
};
// D2. DATA ARRAY
// Each object = one dead-end page.
// url , full URL of dead-end page
// title , display title
// kw , top keyword string (or "")
// tr , monthly traffic integer, or null
// cluster , must match a key in CC
// s , exactly 3 suggestion objects:
// from , source page title
// fromUrl , source page full URL
// anchor , anchor text (no quotes; JS adds them)
// place , placement guidance
const D = [
{
url: "https://www.example.com/blog/reddit-karma-farming-vs-credibility",
title: "Karma Farming vs Credibility on Reddit",
kw: "reddit karma farming",
tr: null,
cluster: "Reddit Marketing",
s: [
{ from: "Dos and Don'ts of Reddit Marketing", fromUrl: "https://www.example.com/blog/reddit-marketing", anchor: "karma farming on Reddit", place: "In the 'What to Avoid' section when listing bad Reddit practices", copy: "When building Reddit presence, understand the risks of <a href='https://www.example.com/blog/reddit-karma-farming-vs-credibility'>karma farming vs building real credibility</a>, the difference determines whether your account earns trust or gets flagged." },
{ from: "Best Subreddits to Join for B2B SaaS Startups", fromUrl: "https://www.example.com/blog/best-subreddits", anchor: "karma vs credibility debate", place: "In the intro when explaining why genuine participation matters" },
{ from: "How Do You Get Karma on Reddit", fromUrl: "https://www.example.com/blog/how-do-you-get-karma-on-reddit", anchor: "karma farming vs building real credibility", place: "After the section on karma-earning tactics, as a cautionary contrast" }
]
}
// … all other dead-end objects
];
// D3. SIDEBAR + SECTION BUILDER, copy verbatim
const clusters = [...new Set(D.map(b => b.cluster))];
const sidebar = document.getElementById('sidebar');
const content = document.getElementById('content');
clusters.forEach(cl => {
const items = D.filter(b => b.cluster === cl);
const link = document.createElement('div');
link.className = 'sidebar-link';
link.dataset.cluster = cl;
link.innerHTML = `<span>${cl}</span><span class="sc">${items.length}</span>`;
link.onclick = () => {
document.getElementById('clusterFilter').value = cl;
filterAll();
document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
link.classList.add('active');
document.getElementById('sec-' + cl.replace(/\s+\//g,'-').replace(/\s+/g,'-'))?.scrollIntoView({behavior:'smooth',block:'start'});
};
sidebar.appendChild(link);
const sec = document.createElement('div');
sec.className = 'cluster-section';
sec.id = 'sec-' + cl.replace(/\s+\//g,'-').replace(/\s+/g,'-');
sec.innerHTML = `<div class="cluster-header"><div class="cluster-title">${cl}</div><span class="cluster-badge">${items.length} blogs</span></div>`;
items.forEach(b => {
const domain = new URL(b.url).hostname.replace('www.','');
const slug = new URL(b.url).pathname.replace(/^\//, '');
const cc = CC[b.cluster] || 'c-other';
let trHtml;
if (b.tr===null||b.tr===undefined) trHtml='<span class="kw-traffic">, </span>';
else if (b.tr>=10) trHtml=`<span class="kw-traffic hot">▲ ${b.tr} visits/mo</span>`;
else if (b.tr>0) trHtml=`<span class="kw-traffic">▲ ${b.tr} visits/mo</span>`;
else trHtml='<span class="kw-traffic">ranking · 0 traffic</span>';
const sugsHtml = b.s.map((s,i) => {
const sDomain = new URL(s.fromUrl).hostname.replace('www.','');
const sSlug = new URL(s.fromUrl).pathname.replace(/^\//, '');
return `<div class="sug-cell">
<div class="sug-num">Suggestion ${i+1}</div>
<div style="font-family:'IBM Plex Mono',monospace;font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px">Link To</div>
<div class="sug-from-title">${s.from}</div>
<a class="sug-from-url" href="${s.fromUrl}" target="_blank">${sDomain}/${sSlug}</a>
<div class="sug-divider"></div>
<div class="sug-anchor-label">Anchor Text</div>
<div class="sug-anchor">"${s.anchor}"</div>
<div class="sug-place-label">Where to Place</div>
<div class="sug-place">${s.place}</div>
<div class="sug-copy-label">Context Copy</div>
<div class="sug-copy">${s.copy||""}</div>
</div>`;
}).join('');
const card = document.createElement('div');
card.className = 'blog-card';
card.dataset.title = b.title.toLowerCase();
card.dataset.url = slug.toLowerCase();
card.dataset.kw = (b.kw||'').toLowerCase();
card.dataset.cluster = b.cluster;
card.innerHTML = `
<div class="blog-header" onclick="this.closest('.blog-card').classList.toggle('open')">
<div class="blog-title-col">
<div class="blog-name">${b.title}</div>
<a class="blog-url" href="${b.url}" target="_blank" onclick="event.stopPropagation()">${domain}/${slug}</a>
</div>
<div class="blog-kw-col">
<span class="kw-pill">${b.kw||', '}</span>
${trHtml}
</div>
<div class="blog-cluster-col"><span class="ctag ${cc}">${b.cluster}</span></div>
<div class="chevron">▼</div>
</div>
<div class="blog-body"><div class="sug-grid">${sugsHtml}</div></div>`;
sec.appendChild(card);
});
content.appendChild(sec);
});
const allLink = document.createElement('div');
allLink.className = 'sidebar-link active';
allLink.innerHTML = `<span>All Clusters</span><span class="sc">${D.length}</span>`;
allLink.onclick = () => {
document.getElementById('clusterFilter').value = '';
filterAll();
document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
allLink.classList.add('active');
};
sidebar.insertBefore(allLink, sidebar.firstChild);
sidebar.insertBefore(Object.assign(document.createElement('div'),{className:'sidebar-heading',textContent:'Clusters'}), allLink.nextSibling);
// D4. FILTER FUNCTION, copy verbatim, do not modify
function filterAll() {
const q = document.getElementById('searchInput').value.toLowerCase();
const cf = document.getElementById('clusterFilter').value;
let visible = 0;
document.querySelectorAll('.blog-card').forEach(card => {
const mq = !q || card.dataset.title.includes(q) || card.dataset.url.includes(q) || card.dataset.kw.includes(q);
const mc = !cf || card.dataset.cluster === cf;
if (mq && mc) { card.classList.remove('hidden'); visible++; }
else card.classList.add('hidden');
});
document.getElementById('vc').textContent = visible;
document.getElementById('noResults').style.display = visible===0 ? 'block' : 'none';
document.querySelectorAll('.cluster-section').forEach(sec => {
const hasVisible = [...sec.querySelectorAll('.blog-card')].some(c => !c.classList.contains('hidden'));
sec.style.display = hasVisible ? '' : 'none';
});
}
</script>
</body>
</html>