SEO + GEO Improvement Skill
End-to-end workflow for making any website discoverable by both traditional search (Google, Bing) and AI search (ChatGPT, Perplexity, Claude, Google AI Overviews). Covers audit → plan → fix → verify in one skill.
Use this when the goal is real traffic and AI citations, not just a green Lighthouse score.
Modes
Pick one before starting. Ask the user if unclear.
- Quick mode (≈10 min) — run Phase 1 only, single-threaded. Triage report with top 5 ship-blockers. Use when the user says "quick check", "is my site broken", "anything critical".
- Deep mode (≈25–40 min wall-clock with parallelism) — run all phases, dispatching 4 parallel subagents via the Agent tool for the heavy phases (see Orchestration). Use when the user says "improve seo", "full audit", "help me rank", "per-page json-ld", "end to end", or describes a traffic goal.
- Team mode — same as deep + live SERP competitor reverse-engineering subagent. Use when the user specifies target keywords or says "beat my competitor".
Orchestration (Deep Mode + Team Mode)
In deep mode, do not walk the phases sequentially. After Preflight, dispatch parallel subagents via the Agent tool in a single message with multiple tool calls. The main thread stays in the orchestrator role: collect results, synthesize, produce the final report.
Step 1 — Preflight on the main thread
Detect stack, classify surface type, confirm traffic goal. Do not skip. These feed every subagent prompt.
Step 2 — Dispatch parallel subagents (ONE message, 4 Agent tool calls)
Use subagent_type: "general-purpose" for all. Every subagent prompt must include:
- Target directory (absolute path)
- Detected framework + rendering mode
- Surface type (marketing / auth app / hybrid)
- Traffic goal (Google / GEO / both / GEO-first)
- Scope (below)
- Required output format
- Word cap
Subagent A — Schema Coverage Mapper
You are mapping JSON-LD structured data coverage for an SEO improvement workflow.
Target: {{absolute path}}
Framework: {{framework}} ({{rendering mode}})
Surface type: {{type}}
Goal: {{traffic goal}}
Your job: per-route JSON-LD coverage matrix.
Steps:
1. Enumerate public routes. Next.js App Router: `fd -e tsx "page\.tsx$" src/app app`. Pages Router: `pages/`. Other: adapt.
2. Classify each route: Home, About, Service, Blog index, Blog post, Product, Tool/SoftwareApp, Case study, Contact, FAQ, Pricing, Landing, Legal, Other.
3. For each route, grep file + layout ancestors for `application/ld+json` / `<JsonLd` / `schema.org`. Record what's injected.
4. Grep for central schema utilities (e.g., `src/seo/schema.ts`) — list defined-but-unused exports (stale code).
Output (markdown, under 600 words):
## Route coverage matrix
| Route | Archetype | Required schemas | Currently injected | Gap |
## Stale schema utilities
- `file::symbol` — defined but never imported
## Top 5 schema priorities (ordered by traffic goal)
1. {{route}} — add {{schema}} — `file:line to inject`
Do NOT write schema code. Plan only.
Subagent B — Technical Sweep
You are auditing technical SEO plumbing: metadata, sitemap, robots, OG, CWV, crawler access.
Target: {{absolute path}}
Framework: {{framework}} ({{rendering mode}})
Surface type: {{type}}
Goal: {{traffic goal}}
Commands to run (adapt per framework):
- Metadata coverage: `rg -l "export const metadata|generateMetadata|<Head>|useSeoTags" src/app app pages 2>/dev/null`
- Inherited-title anti-pattern: check if ONLY the root layout has metadata
- Sitemap: `fd "sitemap\.(ts|xml)$" . -E node_modules` — verify env-driven URLs not hardcoded localhost
- Robots: `cat public/robots.txt 2>/dev/null; fd "robots\.ts$" src/app app` — AI crawler rules? auth route disallows?
- OG images: `fd "opengraph-image" src/app app` — per-route or only root?
- CWV anti-patterns:
- `rg -n "<img " --type tsx | rg -v "width=|height="` (CLS)
- `rg -n "unoptimized.*true" next.config.*` (AVIF/WebP killer)
- `rg -n "next/script" --type tsx | rg -v "lazyOnload|afterInteractive"` (INP)
- noindex in prod: `rg -n "noindex|robots.*index.*false" src/ app/`
- Stale SEO deps: `rg "react-helmet|next-seo" package.json`
- llms.txt: `cat public/llms.txt 2>/dev/null | head -30`
Output (under 700 words, every finding has `file:line`):
## Technical findings
### 🐛 Bugs (ship-blockers)
- {{issue}} — `file:line` — {{fix}}
### ❌ Missing (high impact)
### ⚠️ Partial (quick wins)
### ✅ Strengths
## Top 5 technical priorities (ordered by traffic goal)
1. ...
Subagent C — Content Shape + GEO Readiness
You are auditing content structure for GEO — making the site citable by ChatGPT, Claude, Perplexity, Google AI Overviews.
Target: {{absolute path}}
Framework: {{framework}}
Surface type: {{type}}
Goal: {{traffic goal}}
Your job: audit the top 5 public content pages (home, top services, top blog posts, top tools). NOT JSON-LD (another agent handles that). Content shape + entity signals only.
For each page, open component + any MDX/content source and check:
- H2-as-questions: are section headings framed as questions AI tools would be asked?
- Lead sentence test: short declarative sentence per section that stands alone when extracted?
- Lists & tables for comparable data (AI cites these more than prose)
- Entity names in plain text (not only in logos/images)
- Semantic landmarks: `<article>`, `<section>`, `<nav>`, `<main>`, `<time datetime="...">`, `<figure>`/`<figcaption>`
- Visible dates: `dateModified` shown to users, not only schema
- Author byline linked to dedicated author page
- Internal citations to authoritative primary sources
Also check:
- `public/llms.txt` — exists? up to date? covers top pages?
- Entity signals on home/about: Person/Organization with rich `sameAs` (LinkedIn, GitHub, Wikipedia, Scholar)?
Output (under 700 words):
## Content shape findings (per page)
### /{{route}}
- H2-as-questions: ✅/⚠️/❌ — {{notes}}
- Lead sentences: ...
- ...
## llms.txt status
## Entity signals status
## Top 5 content priorities (ordered by traffic goal)
Subagent D — Crawler Simulation (requires live URL)
Only dispatch if user provides a deployed URL. If pre-launch, skip and note it.
You are running live crawler simulation against a deployed site to verify what AI search engines actually see. Catches CSR SPAs invisible to non-JS crawlers.
Target URL: {{url}}
Surface type: {{type}}
Run:
URL="{{url}}"
for UA in \
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
"GPTBot/1.2 (+https://openai.com/gptbot)" \
"OAI-SearchBot/1.0 (+https://openai.com/searchbot)" \
"ChatGPT-User/1.0 (+https://openai.com/bot)" \
"ClaudeBot/1.0 (+claudebot@anthropic.com)" \
"PerplexityBot/1.0 (+https://perplexity.ai/perplexitybot)" \
"Google-Extended" \
"Applebot-Extended" \
"CCBot/2.0"; do
echo "=== $UA ==="
RESP=$(curl -sL -A "$UA" -w "\nSTATUS:%{http_code}" "$URL")
echo "$RESP" | tail -1
echo " h1: $(echo "$RESP" | grep -c '<h1')"
echo " h2: $(echo "$RESP" | grep -c '<h2')"
echo " JSON-LD: $(echo "$RESP" | grep -c 'application/ld\+json')"
echo " og:title: $(echo "$RESP" | grep -c 'og:title')"
echo " title: $(echo "$RESP" | grep -oE '<title[^>]*>[^<]*' | head -1)"
echo " bytes: $(echo "$RESP" | wc -c)"
done
curl -sL "$URL/robots.txt" | head -30
curl -sI "$URL/sitemap.xml"
curl -sI "$URL/llms.txt"
Output (under 500 words):
## Per-crawler visibility matrix
| UA | Status | h1 | h2 | JSON-LD | og:title | Bytes | Title |
## Verdict
One sentence per crawler class.
## 🐛 Bugs
Any crawler seeing 0 content / 0 json-ld / 0 h1 is a bug.
Subagent E — SERP Competitor Reverse-Engineer (Team mode only)
Only dispatch if user provides target keywords. Uses WebFetch.
You are reverse-engineering the top 10 Google SERP results for target keyword(s), extracting patterns the user's site can adopt.
Target keyword(s): {{keywords}}
User's site: {{url}}
Steps:
1. WebFetch `https://www.google.com/search?q={{url-encoded keyword}}` — if Google blocks, ask user for a raw SERP dump
2. Extract top 10 organic (skip ads, knowledge panels unless relevant)
3. For each competitor URL, WebFetch and extract:
- Title tag length + pattern
- Meta description
- H1 text
- H2 count + framing (question vs statement)
- Rough word count
- Schema types (grep `@type":` in HTML)
- Internal/external link counts
- Image count + alt quality
4. Identify patterns: common schema, heading framings, length norms, SERP feature targets.
Output (under 700 words):
## SERP top 10 — {{keyword}}
| Rank | Domain | Title | Words | H2s | Schemas |
## Patterns to adopt
- Most ranking pages use {{pattern}}
- Schema cluster: top 5 all inject {{types}}
- Average content length: {{n}} words (user's: {{m}})
- Common H2 framings: {{examples}}
## What the winners do that the user doesn't
1. ...
Step 3 — Synthesize on the main thread
Collect all subagent outputs. Do not forward raw to the user. Produce a unified report using the Output Format below, with findings re-ordered by the traffic goal chosen in Preflight. Rule 10 is load-bearing.
Step 4 — Ask before touching code
After the report, ask which findings to implement. No silent edits. Architectural findings (SPA → SSR) get 2–3 options with tradeoffs. Rule 9.
Step 5 — Handoff to seo-measure
Tell the user: "To compound this into 20-year-expert-level knowledge for YOUR specific site, run seo-measure weekly. Audit is a snapshot; measure is the learning system." Then optionally invoke seo-measure to capture the baseline.
Preflight — Always Run First
Before any phase, establish three things. Do not skip.
1. Stack detection
Read package.json, then whichever config file exists: next.config.*, vite.config.*, astro.config.*, svelte.config.*, nuxt.config.*, gatsby-config.*, remix.config.*.
Record: framework, major version, rendering mode (SSR / SSG / ISR / CSR / hybrid), styling (affects CLS checks), i18n library if any.
Rendering mode determines what's possible. A CSR-only SPA cannot be made AI-discoverable by a checklist — it needs a framework discussion. Surface this as Finding #1 if detected, not buried in a category.
2. Surface-type classification
Ask if unclear. Three archetypes:
- Marketing site — public-facing, traffic is the goal. Full treatment.
- Authenticated app — login-gated. Goal is blocking crawlers from auth/dashboard/onboarding, not courting them. Skip OG/sitemap work except for public routes (landing, pricing, invite-accept).
- Hybrid — marketing pages + app routes in same codebase. Audit the two surfaces separately. Most Next.js SaaS products are this shape.
3. Traffic goal
Ask the user which outcome they're optimizing for. Shapes prioritization:
- (i) Google organic — classic SEO. Prioritize indexability, Core Web Vitals, keyword coverage, schema eligible for rich results.
- (ii) AI citations — GEO. Prioritize server-rendered HTML, entity markup, semantic structure, llms.txt, content shape (H2-as-questions, short lead sentences, lists/tables).
- (iii) Both equally — default.
- (iv) Both, GEO-first — for sites already strong on Google but invisible to AI.
Phase 1 — Audit (always run)
Work through all 10 categories. For every item: ✅ / ⚠️ / ❌ / 🐛 with file:line evidence. Never mark present without proof.
Before starting, run these commands to get real data:
# Inventory every route in the codebase (Next.js App Router example)
# Prefer `find` over `fd` — `fd` is not always installed; Glob can silently
# return empty on some repos, so fall back to find.
find src/app app -name "page.tsx" -o -name "page.ts" 2>/dev/null
# Find all metadata exports
rg -n "export const metadata|generateMetadata|<Head>|useSeoTags|react-helmet" --type ts --type tsx
# Find all JSON-LD injections
rg -n "application/ld\+json|<JsonLd|jsonLd|schema\.org" --type ts --type tsx
# CRITICAL ANTI-PATTERN CHECK — JSON-LD rendered as <meta> instead of <script>.
# In Next.js App Router, putting JSON-LD in metadata.other serializes it as
# <meta name="application/ld+json" content="...">, NOT as a script tag.
# Google and all AI crawlers ignore it. The #1 real-world Next.js SEO bug.
rg -nC2 '"application/ld\+json"' --type ts --type tsx | rg -B2 -A2 "other:|other ="
# Find the public SEO files — sitemap may be at ANY of these paths
ls public/robots.txt public/sitemap.xml public/llms.txt public/manifest.json 2>/dev/null
find src/app app -name "robots.ts" -o -name "robots.tsx" 2>/dev/null
find src/app app -name "sitemap.ts" -o -name "sitemap.tsx" 2>/dev/null
find src/app app -path "*sitemap.xml/route.ts" 2>/dev/null # folder-as-route pattern
# Grep for noindex (must not appear in prod build)
rg -n "noindex|robots.*index.*false|X-Robots-Tag" --type ts --type tsx
# CWV anti-patterns
rg -n "<img " --type tsx | rg -v "width=|height="
rg -n "unoptimized.*true" --type ts
rg -n "dangerouslyAllowSVG.*true" --type ts # supply-chain risk
# Third-party <Script> without strategy="lazyOnload" — afterInteractive
# (the Next.js default) still blocks INP on weak devices.
rg -nC1 '<Script' --type tsx | rg -v 'lazyOnload'
# Fake AggregateRating — Google spam-policy landmine
rg -n '"AggregateRating"|aggregateRating' --type ts --type tsx
# For each hit, verify the rating is backed by real visible review data.
Categories
- Framework fit & rendering — SSR/SSG for public routes? Prerender for SPAs? No global static export on web?
- Per-route metadata — unique title (50–60 chars) + description (140–160 chars) per public route? Canonical per route?
<html lang="...">set? No inherited-root-title anti-pattern? - robots.txt + sitemap.xml — exist, env-driven URLs (no hardcoded localhost), auth routes disallowed, AI crawler rules explicit, sitemap referenced from robots.
- Structured data (JSON-LD) — see Phase 2 for the coverage matrix. Here just note: does any JSON-LD exist? Is it in
<head>? Are required fields populated? - Open Graph + Twitter — minimum set present, per-route OG images, 1200×630 PNG/JPG (not AVIF), absolute URLs.
- Core Web Vitals — LCP causes (images without priority, fonts without preload, render-blocking JS), INP causes (heavy handlers, third-party scripts without lazyOnload), CLS causes (images without width/height, fonts without size-adjust).
- GEO readiness —
llms.txt? AI crawler rules in robots.txt? Server-rendered HTML? Semantic HTML landmarks? Entity markup on author/org? - Accessibility-as-SEO — one h1 per page, heading order, alt text, descriptive link text, form labels, landmarks.
- Indexability bugs — any
noindexin prod output? Blocked/_next/static/? Redirect chains? Soft 404s? Infinite URL spaces? - Monitoring baseline — GSC verified? Bing Webmaster? Lighthouse CI? Vercel Speed Insights / RUM?
Stale-code cleanup (cross-cut, always check)
- JSON-LD utility defined in code but never imported by any page? Flag as stale.
react-helmet+ framework-native metadata both present? Pick one.- Old
next-seousage alongside App Routermetadata? Migrate. useEffect-injected meta tags? Invisible to non-JS crawlers — flag.- Before recommending removal of any dependency, grep
src/to confirm it's actually unused. A stalepackage.jsonentry and a live import look the same frompackage.jsonalone — removing a live dep breaks the build.
Phase 2 — Per-Page JSON-LD Planning
The single highest-leverage GEO move. Most sites have zero JSON-LD or a sitewide Organization and nothing else. Per-page schema is what drives rich results and AI citations.
Step 1: Build the coverage matrix
Map every public route to the schema type(s) it should have. Skip auth/app routes.
| Route | Archetype | Required schemas | Current state |
|---|---|---|---|
/ |
Home | WebSite + SearchAction, Organization OR Person |
❌ missing |
/about |
About | AboutPage, Person (with sameAs) |
❌ |
/services |
Services index | ItemList of Service, optional FAQPage |
❌ |
/services/[slug] |
Service | Service, BreadcrumbList, FAQPage if applicable |
❌ |
/blog |
Blog index | Blog, BreadcrumbList |
❌ |
/blog/[slug] |
Blog post | BlogPosting, BreadcrumbList, Person (author) |
❌ |
/blog/[slug] (tutorial) |
Blog post + how-to | BlogPosting + HowTo, BreadcrumbList |
❌ |
/tools |
Tools index | ItemList of SoftwareApplication |
❌ |
/tools/[slug] |
Tool/product | SoftwareApplication OR Product, BreadcrumbList, AggregateRating only if reviews are real and visible |
❌ |
/projects/[slug] |
Case study | CreativeWork OR Article, BreadcrumbList |
❌ |
/testimonials |
Testimonials | CollectionPage + Review array |
❌ |
/team |
Team | CollectionPage of Person |
❌ |
/contact |
Contact | ContactPage, optional Organization with contactPoint |
❌ |
/faq |
FAQ | FAQPage, BreadcrumbList |
❌ |
/pricing |
Pricing | Product + Offer array (for rich results), OR Service + PriceSpecification |
❌ |
Present this matrix to the user before writing any schemas. Get confirmation on priorities.
Common mistakes filling this matrix:
- Forgetting
BreadcrumbListon every non-home route — required for rich-results eligibility. - Marking a route ✅ based on grep match alone without verifying the schema is injected as a
<script>tag (see themetadata.otheranti-pattern). - Recommending
AggregateRatingwithout real review data. - Missing
HowToopportunity on tutorial-style blog posts.
Step 2: Templates (copy-paste, fill in, inject)
All templates below go in <script type="application/ld+json"> in <head> (or via framework-native method). Use absolute URLs. Validate at Google Rich Results Test before shipping.
WebSite + SearchAction (home page only):
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{{site name}}",
"url": "{{site url}}",
"potentialAction": {
"@type": "SearchAction",
"target": { "@type": "EntryPoint", "urlTemplate": "{{site url}}/search?q={search_term_string}" },
"query-input": "required name=search_term_string"
}
}
Person (home / about / blog author — the entity signal that matters most for GEO):
{
"@context": "https://schema.org",
"@type": "Person",
"name": "{{full name}}",
"url": "{{canonical profile url}}",
"image": "{{absolute headshot url}}",
"jobTitle": "{{role}}",
"worksFor": { "@type": "Organization", "name": "{{org}}", "url": "{{org url}}" },
"sameAs": [
"https://linkedin.com/in/{{handle}}",
"https://github.com/{{handle}}",
"https://x.com/{{handle}}",
"https://scholar.google.com/citations?user={{id}}"
],
"knowsAbout": ["{{topic1}}", "{{topic2}}", "{{topic3}}"],
"alumniOf": { "@type": "Organization", "name": "{{school}}" }
}
sameAs is the critical field — it lets AI search engines resolve "who is this person" across the web. Include LinkedIn, GitHub, X, Wikipedia/Wikidata if it exists, Google Scholar, personal site.
Organization (home page, if not a personal site):
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "{{org name}}",
"url": "{{site url}}",
"logo": "{{absolute logo url, min 112x112}}",
"sameAs": ["{{linkedin}}", "{{x}}", "{{github}}", "{{youtube}}"],
"contactPoint": {
"@type": "ContactPoint",
"email": "{{contact email}}",
"contactType": "customer support"
}
}
BlogPosting (every blog post):
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "{{post title, ≤110 chars}}",
"description": "{{post excerpt}}",
"image": ["{{absolute hero image url, 1200x630}}"],
"datePublished": "{{ISO 8601}}",
"dateModified": "{{ISO 8601}}",
"author": {
"@type": "Person",
"name": "{{author name}}",
"url": "{{author page url}}"
},
"publisher": {
"@type": "Organization",
"name": "{{site name}}",
"logo": { "@type": "ImageObject", "url": "{{absolute logo url}}" }
},
"mainEntityOfPage": { "@type": "WebPage", "@id": "{{absolute post url}}" },
"keywords": ["{{kw1}}", "{{kw2}}"],
"articleSection": "{{category}}",
"wordCount": {{int}}
}
Required-field gotchas: image must be an array, author must be an object not a string, datePublished/dateModified must be ISO 8601, headline must not exceed 110 chars or Google truncates it.
Service (consulting / agency service pages):
{
"@context": "https://schema.org",
"@type": "Service",
"name": "{{service name}}",
"description": "{{short desc}}",
"provider": { "@type": "Person or Organization", "name": "{{name}}", "url": "{{url}}" },
"serviceType": "{{category}}",
"areaServed": "{{Worldwide or country/city}}",
"offers": {
"@type": "Offer",
"priceCurrency": "USD",
"price": "{{amount}}",
"availability": "https://schema.org/InStock"
},
"hasOfferCatalog": {
"@type": "OfferCatalog",
"name": "{{catalog name}}",
"itemListElement": [
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "{{sub-service}}" } }
]
}
}
SoftwareApplication (tools, SaaS, calculators):
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "{{app name}}",
"description": "{{short desc}}",
"applicationCategory": "{{BusinessApplication | DeveloperApplication | etc}}",
"operatingSystem": "Web",
"url": "{{app url}}",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{4.8}}",
"reviewCount": "{{int}}"
}
}
Only include aggregateRating if the ratings are real and visible on the page. Google penalizes fake review markup.
FAQPage (any page with a real FAQ section visible to users):
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "{{question text, matches h2/h3 on page}}",
"acceptedAnswer": { "@type": "Answer", "text": "{{answer text}}" }
}
]
}
Critical: the questions/answers in JSON-LD must exactly match what's rendered on the page. Google penalizes mismatches.
BreadcrumbList (every non-home page):
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "{{site url}}" },
{ "@type": "ListItem", "position": 2, "name": "{{section}}", "item": "{{section url}}" },
{ "@type": "ListItem", "position": 3, "name": "{{page title}}", "item": "{{page url}}" }
]
}
Step 3: Injection strategy per framework
- Next.js App Router → helper function in
src/seo/schema.tsthat returns the JSON object, then<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />inside the page or layout component. Do NOT useJSON.stringifyinsidegenerateMetadata— metadata API doesn't handle script tags. - Next.js Pages Router → same, inside
<Head>fromnext/head. - Vite/React SPA → React 19 native
<script>in component, orreact-helmet-asyncfor React 18. If SSR isn't enabled, the JSON-LD will only reach JS-executing crawlers. - Astro → slot into
<Fragment slot="head">. - SvelteKit →
<svelte:head>in+page.svelte.
Step 4: Validate
For each schema added:
# Local validation — paste URL into the form
open "https://search.google.com/test/rich-results?url={{url}}"
open "https://validator.schema.org/?url={{url}}"
Run the Rich Results test on at least one page per archetype before merging.
Phase 3 — GEO Structural Improvements
GEO is mostly content shape + entity signals + server-rendered HTML. JSON-LD is necessary but not sufficient.
3a. Crawler access (robots.txt)
Every public site should have explicit rules for AI crawlers. Default template:
# robots.txt
User-agent: *
Allow: /
# Traditional search
Sitemap: https://{{site}}/sitemap.xml
# AI search engines — ALLOW (change to Disallow if you want out of AI answers)
User-agent: GPTBot # OpenAI training
Allow: /
User-agent: OAI-SearchBot # ChatGPT Search indexer
Allow: /
User-agent: ChatGPT-User # User-triggered browse in ChatGPT
Allow: /
User-agent: ClaudeBot # Anthropic crawler
Allow: /
User-agent: anthropic-ai # Legacy Anthropic UA
Allow: /
User-agent: PerplexityBot # Perplexity indexer
Allow: /
User-agent: Perplexity-User # User-triggered Perplexity fetch
Allow: /
User-agent: Google-Extended # Gemini training (separate from Googlebot)
Allow: /
User-agent: Applebot-Extended # Apple Intelligence training
Allow: /
User-agent: CCBot # Common Crawl (feeds many AI training sets)
Allow: /
# Block if authenticated app routes exist
Disallow: /dashboard/
Disallow: /auth/
Disallow: /onboarding/
Disallow: /api/
Disallow: /admin/
Key distinctions (cite to the user if they push back):
- Blocking
GPTBotremoves your content from OpenAI training, not from ChatGPT search. Search usesOAI-SearchBot+ChatGPT-User. Google-Extendedcontrols Gemini;Googlebotcontrols Google Search indexing. They're separate — you can opt out of AI training while staying in search.Applebot= Siri/Spotlight/Safari search.Applebot-Extended= Apple Intelligence training. Again separate.
3b. llms.txt (low-cost hygiene, not a ranking signal)
Place at public/llms.txt. Follows llmstxt.org convention. Template:
# {{Site Name}}
> {{One-sentence site description — this is what AI tools see first.}}
{{Longer paragraph: who you are, what the site is for, key expertise areas.}}
## Key pages
- [Home]({{site url}}): {{one-line summary}}
- [About]({{site url}}/about): {{one-line summary}}
- [Services]({{site url}}/services): {{one-line summary}}
- [Blog]({{site url}}/blog): {{one-line summary}}
## Services
- [{{service}}]({{url}}): {{one-line}}
## Recent articles
- [{{post title}}]({{url}}): {{one-line}}
## Contact
- Email: {{email}}
- LinkedIn: {{url}}
- GitHub: {{url}}
Honest framing for the user: no primary source (OpenAI, Anthropic, Google, Perplexity) confirms llms.txt is a ranking signal. It's a discoverability convenience. Install it because it's cheap, not because it'll move the needle alone.
3c. Content shape (the real GEO lever)
AI search extracts and cites. What gets extracted:
- H2s as questions. "How does X work?" outperforms "Overview of X". LLMs are trained on Q&A data.
- Short declarative lead sentence per section. First sentence is extracted disproportionately. Make it stand alone.
- Lists and tables for comparable data. Cited more often than prose. Use for pricing tiers, feature matrices, step-by-step.
- Explicit entity names in plain text. Don't put product names only in logos/images — LLMs can't read them.
- Semantic HTML landmarks.
<article>,<section>,<nav>,<main>,<time datetime="...">,<figure>+<figcaption>. AI extractors use these. - Citations to primary sources. LLMs prefer citing content that itself cites. Link out to authoritative sources in body copy.
- Dates visible in content. Recency matters — AI answers prefer fresh content. Show
dateModifiedas a visible line, not only in schema. - Author byline with link. Entity resolution depends on the author having their own URL + schema.
When auditing content pages, flag each of these as ✅/❌ per page.
3d. Entity signals
PersonorOrganizationJSON-LD on home page with richsameAs(see Phase 2 templates).- External presence: Wikipedia/Wikidata entry if possible (biggest entity-resolution boost), LinkedIn Company or Personal profile, GitHub org, Crunchbase for companies.
- Internal linking to a canonical "about" page from every article byline.
Phase 4 — Technical Fixes
Order matters — do these top-down.
4a. Per-route metadata (if Phase 1 found inheritance anti-pattern)
For every public route, ensure unique title + description + canonical. Framework-specific:
- Next.js App Router:
export const metadata: Metadata = { ... }for static,generateMetadatafor dynamic. Always setalternates.canonicalto an absolute URL. - React 19 SPA:
<title>,<meta>,<link>in component JSX — React hoists to<head>. - React 18 SPA:
react-helmet-async(notreact-helmet— unmaintained).
4b. Sitemap.xml
Template for Next.js App Router (src/app/sitemap.ts):
import type { MetadataRoute } from 'next';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticRoutes = ['', '/about', '/services', '/blog', '/contact'].map(path => ({
url: `${SITE_URL}${path}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: path === '' ? 1.0 : 0.8,
}));
// Dynamic routes — pull from CMS/filesystem/db
const posts = await getAllPosts();
const postRoutes = posts.map(p => ({
url: `${SITE_URL}/blog/${p.slug}`,
lastModified: new Date(p.updatedAt),
changeFrequency: 'monthly' as const,
priority: 0.6,
}));
return [...staticRoutes, ...postRoutes];
}
Rules:
SITE_URLenv-driven, never hardcoded. Validate it doesn't point to localhost or a preview URL.- Never include auth-gated routes.
- Under 50k URLs per file; use sitemap index if exceeded.
lastModifiedmust be real — stale dates hurt more than help.
4c. OG images
Per-route OG images are table stakes. Two approaches:
- Static (simpler): place
opengraph-image.png(1200×630) next to eachpage.tsx. Next.js auto-exports as metadata. - Dynamic (blog/products):
opengraph-image.tsxusingnext/og:
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function OG({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return new ImageResponse(
<div style={{ display: 'flex', fontSize: 64, background: '#000', color: '#fff', width: '100%', height: '100%', padding: 80 }}>
{post.title}
</div>
);
}
Validate with opengraph.xyz + LinkedIn Post Inspector (aggressive cache — may need force-refresh).
4d. Core Web Vitals fixes
Top five fixes, in order of typical impact:
- LCP image → add
priorityprop onnext/image, setfetchpriority="high", add explicitwidth/height. - Fonts →
next/fontorfont-display: swap+ preload only above-fold weights. - Third-party scripts →
next/script strategy="lazyOnload"for analytics,afterInteractivefor auth. - CLS from images → grep every
<img>without dimensions, add them. - INP from heavy effects → wrap state updates in
useTransition, defer non-critical work.
4e. Stale code cleanup
After all fixes, grep for anything left behind:
rg -n "react-helmet" --type tsx # should be gone if migrated
rg -n "next-seo" --type ts # should be gone if App Router metadata is used
rg -n "useEffect.*document\.title" # imperative title hack — should be gone
Delete, don't comment out.
Phase 5 — Verification (real, not theoretical)
Never declare done without running these.
5a. Crawl simulation — the fastest GEO check
This catches CSR SPAs that are invisible to AI crawlers. If h1 count is 0 when user-agent is PerplexityBot, the site is not discoverable by Perplexity regardless of JSON-LD.
URL="{{target url}}"
# Googlebot — should work even on SPAs (Google renders JS)
curl -sL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" "$URL" | grep -c "<h1"
# PerplexityBot — will NOT execute JS
curl -sL -A "PerplexityBot/1.0" "$URL" | grep -c "<h1"
# ClaudeBot
curl -sL -A "ClaudeBot/1.0 (+claudebot@anthropic.com)" "$URL" | grep -c "<h1"
# GPTBot
curl -sL -A "GPTBot/1.2 (+https://openai.com/gptbot)" "$URL" | grep -c "<h1"
# Check JSON-LD is present in raw HTML (not injected post-hydration)
curl -sL -A "GPTBot/1.2" "$URL" | grep -o 'application/ld+json' | wc -l
Expected: every UA returns ≥1 <h1> and ≥1 JSON-LD script. If Googlebot returns content but PerplexityBot doesn't, the site is CSR-only — server rendering is required.
5b. Lighthouse
# One-off
npx lighthouse "$URL" --only-categories=seo,performance,accessibility,best-practices --view
# CI — budgets file
npx @lhci/cli autorun --config=.lighthouserc.json
Budgets to assert:
- Performance ≥ 90, SEO = 100, Accessibility ≥ 95, Best Practices ≥ 95
- LCP ≤ 2500ms, INP ≤ 200ms, CLS ≤ 0.1
5c. Rich Results + schema validation
For each archetype (home, blog post, service, tool, FAQ):
open "https://search.google.com/test/rich-results?url={{url}}"
open "https://validator.schema.org/?url={{url}}"
Fix any errors before shipping. Warnings are optional but worth reading.
5d. Search Console baseline
If Google Search Console is verified, capture the pre-fix baseline (so post-fix improvements are provable):
- Coverage: # indexed, # excluded, error types
- Performance: impressions, clicks, CTR, average position — last 28 days
- Core Web Vitals: URLs in Good / Needs improvement / Poor (field data)
- Enhancements: which schema types Google recognizes
If not verified, add it to Phase 6 as a follow-up.
5e. AI citation check (manual, 5 min)
Ask ChatGPT, Claude, and Perplexity directly:
Prompts:
1. "What is {{site name}}?"
2. "Who is {{author/org name}}?"
3. "What services does {{site/org}} offer?"
4. "Find articles about {{topic}} by {{author}}"
Record whether the site is cited. Zero citations = not discoverable. This is the only metric that matters for GEO.
Phase 6 — Monitoring Setup (if not already in place)
Leave-behinds that let the user measure without running the skill again:
- Google Search Console verified on apex + www, sitemap submitted
- Bing Webmaster Tools imported from GSC (feeds ChatGPT Search via Bing)
- Lighthouse CI with budgets in the repo (
.lighthouserc.json) - Vercel Speed Insights or equivalent RUM for field data
- Monthly calendar reminder: grep server logs for AI crawler hits (
GPTBot,ClaudeBot,PerplexityBot). Zero hits = still invisible.
Output Format
# SEO + GEO Report — {{site}}
**Stack:** {{framework + rendering}}
**Surface type:** {{marketing / app / hybrid}}
**Traffic goal:** {{google / geo / both}}
**Mode:** {{quick / deep}}
**Date:** {{YYYY-MM-DD}}
## Phase 1 — Audit
| # | Category | Status | Notes |
|---|----------|--------|-------|
...
## Findings (by severity)
### 🐛 Bugs (ship-blockers)
...
### ❌ Missing (high impact)
...
### ⚠️ Partial (quick wins)
...
### ✅ Strengths (keep)
...
## Phase 2 — JSON-LD Coverage Matrix
[route → archetype → required schemas → current state table]
## Phase 3 — GEO Readiness
- Crawler access: ...
- llms.txt: ...
- Content shape: ... (per priority page)
- Entity signals: ...
## Phase 4 — Technical Fix List (ordered)
1. ...
## Phase 5 — Verification Results
- Crawl simulation: ...
- Lighthouse: ...
- Rich Results test: ...
- AI citation check: ...
## Top 5 This-Week Actions
1. ...
## Top 5 This-Month Actions
1. ...
## Unresolved / Needs User Input
- ...
Rules
- Never mark "Present" without
file:lineevidence. - Match the audit to the surface type. Do not recommend sitemap/OG work for an auth-only app.
- Options, not verdicts, on tradeoffs. Present 2–3 options with concrete tradeoffs for architectural choices. Recommend A explicitly.
- GEO is hygiene, not magic.
llms.txtis not a proven ranking signal. Install it as low-cost discoverability, never overclaim. - Flag framework mismatches loudly. A CSR SPA that needs AI discoverability requires a framework discussion, not a checklist item. Surface as Finding #1.
- No generic SEO preambles. Every output line must be specific to the codebase in front of you.
- Respect post-refactor cleanup. After recommending fixes, list any stale SEO code that would be orphaned (unwired JSON-LD utilities, dead
react-helmetimports, commented-outmetatags). - Verify before declaring done. Phase 5 is not optional in deep mode. If tooling isn't available (no curl, no lighthouse, no GSC access), state that explicitly.
- Do not silently start editing code. After producing the report, ask the user which findings to implement. Never touch code without explicit go-ahead.
- Honor the traffic goal. A user optimizing for AI citations should not get 20 Core Web Vitals fixes before JSON-LD and content shape. Re-order findings by goal.
Known Anti-Patterns
Every anti-pattern below has an exact grep signature. Run them all in Phase 1 — these catch more real bugs than any general checklist ever will.
JSON-LD rendered as
<meta>instead of<script>— the #1 real-world Next.js App Router SEO bug.metadata.other["application/ld+json"]: JSON.stringify(schema)looks right but Next.js serializesotheras<meta name="application/ld+json" content="...">, invisible to Google and all AI crawlers. Inject via<script type="application/ld+json" dangerouslySetInnerHTML={{__html: JSON.stringify(schema)}} />in the layout/page body, not via metadata.- Signature:
rg -nC2 '"application/ld\+json"' --type ts --type tsx | rg -B2 -A2 "other:|other ="
- Signature:
Inherited root title across all routes — every page sharing one title tag. Common in SPA → Next.js migrations.
- Signature:
rg -l "export const metadata|generateMetadata" src/app app— if only the root layout matches, flag.
- Signature:
Global
images: { unoptimized: true }innext.config.*— kills AVIF/WebP + responsive sizing. Only acceptable gated on a Capacitor/mobile build env var.- Signature:
rg -n "unoptimized.*true" next.config.*
- Signature:
dangerouslyAllowSVG: truein next.config image block — supply-chain risk for remote SVGs even with CSP sandbox.- Signature:
rg -n "dangerouslyAllowSVG.*true" next.config.*
- Signature:
Third-party
<Script>withoutstrategy="lazyOnload"— evenafterInteractive(the Next.js default) blocks INP on weak devices. Analytics, widgets, chat launchers should always belazyOnload.- Signature:
rg -nC1 '<Script' --type tsx | rg -v 'lazyOnload'
- Signature:
Fake
AggregateRatingon free tools / calculators —ratingValue: "5.0", reviewCount: "1"hardcoded = Google spam policy. Manual-action territory.- Signature:
rg -n '"AggregateRating"|aggregateRating' --type ts --type tsx— verify every hit is backed by real visible review data.
- Signature:
WebSite+SearchActionwith stringtarget— Google's spec requires{ "@type": "EntryPoint", "urlTemplate": "..." }object form. Plain strings fail Sitelinks Search Box validation.- Signature:
rg -nB2 -A4 'SearchAction' --type ts— checktargetfield shape.
- Signature:
JSON-LD utility defined but never imported — schemas live in code, no page injects them. Either wire up or delete.
- Signature: for each
export function generate*Schemainsrc/seo/schema.ts, verify at least one import exists elsewhere insrc/.
- Signature: for each
Client-only home page (
"use client"onsrc/app/page.tsx) — the highest-GEO-value route should be server-rendered so async data + server-only schema generation work.- Signature:
head -1 src/app/page.tsx 2>/dev/null | grep "use client"— flag if matched.
- Signature:
useEffect-injecteddocument.title/ meta tags — invisible to non-JS crawlers.- Signature: `rg -n "useE
…(truncated)