seo-preflight is a free Agent Skill that makes your coding agent run a pre-launch SEO and GEO audit of a static or Next.js site — titles, meta descriptions, canonicals, Open Graph, JSON-LD validity, one H1 per page, broken internal links, sitemap.xml, robots.txt, llms.txt, IndexNow and answer-first copy — and return a prioritized P0–P3 fix list it can then apply.
What it does
- Runs a zero-dependency Python checker (embedded in the skill) over your build output
- Flags P0 launch blockers: broken internal links, invalid JSON-LD, robots.txt blocking the site, missing sitemap
- Flags P1/P2 issues: missing or duplicate titles and descriptions, canonical errors, multiple or missing H1s, pages missing from the sitemap, missing OG tags, thin pages, missing llms.txt
- Adds the judgment checks a script can't do: search-intent titles, answer-first paragraphs for AI answer engines, JSON-LD that matches visible content, Next.js metadataBase pitfalls
- Optionally fixes issues at the source (templates, generateMetadata, data files), rebuilds and re-checks until P0 = P1 = 0, then pings IndexNow
Who it's for
- Developers shipping a marketing site, docs site or blog with Next.js, Astro, Hugo, Eleventy or plain HTML
- Indie hackers who want an SEO checklist enforced by the agent before every deploy
- Teams using Claude Code, Codex or Cursor to build pages fast — and breaking canonicals and sitemaps just as fast
Real case: getaicodingagent.com
This is the gate every deploy of getaicodingagent.com passes: the site is generated from data by a Python build, and a checker like the one in this skill must report zero problems before anything ships. Running the skill's script on our own 57-page build on Oct 12, 2026 returned READY — no P0/P1 issues — plus a short honest P2 list we're now working through (a few long titles and descriptions, a thin About page) and a P3 note that pages lack og:image.
Example output
$ python3 /tmp/seo_preflight.py ./site https://getaicodingagent.com
P2 description-length best/free-ai-coding-agents.html 217 chars
P2 description-length index.html 189 chars
P2 thin-content about.html ~183 words
P2 title-too-long tools/jetbrains-ai.html 74 chars: JetBrains AI (Junie & AI Assistant) Review: …
P2 title-too-long tools/windsurf.html 67 chars: Windsurf (now Devin Desktop) Review: …
P3 missing-og-image index.html
…
SUMMARY pages=57 indexable=56 P0=0 P1=0 P2=10 P3=57 indexnow_key=384a…ca.txt
## SEO preflight — getaicodingagent.com — 2026-10-12
Result: READY (P0=0 P1=0 P2=10 P3=57, 57 pages)
### Top 3 changes with the biggest impact
1. Generate og:image cards for every page (P3 × 57, but it's one template change)
2. Shorten 4 titles to ≤ 60 chars so the keyword isn't truncated in results
3. Expand /about with methodology details (thin page, E-E-A-T)Install
One line — all three agents
for d in ~/.claude/skills ~/.agents/skills; do mkdir -p "$d/seo-preflight" && curl -fsSL https://getaicodingagent.com/skills/seo-preflight/SKILL.md -o "$d/seo-preflight/SKILL.md"; doneInstalls to ~/.claude/skills (Claude Code) and ~/.agents/skills (Codex and Cursor). Restart the agent afterwards.
Claude Code
mkdir -p ~/.claude/skills/seo-preflight && curl -fsSL https://getaicodingagent.com/skills/seo-preflight/SKILL.md -o ~/.claude/skills/seo-preflight/SKILL.mdPersonal skill for all projects. For one project, use .claude/skills/seo-preflight/SKILL.md in the repo and commit it. Run it with /seo-preflight or just describe the task. Claude Code skills docs.
OpenAI Codex
mkdir -p ~/.agents/skills/seo-preflight && curl -fsSL https://getaicodingagent.com/skills/seo-preflight/SKILL.md -o ~/.agents/skills/seo-preflight/SKILL.mdUser-level skill. For a repository, use .agents/skills/seo-preflight/SKILL.md (Codex scans .agents/skills from the working directory up to the repo root). Restart Codex; it picks the skill from its description, or name it in your prompt. Codex skills docs.
Cursor
mkdir -p .cursor/skills/seo-preflight && curl -fsSL https://getaicodingagent.com/skills/seo-preflight/SKILL.md -o .cursor/skills/seo-preflight/SKILL.mdRun in your project root (project skill). For all projects use ~/.cursor/skills/seo-preflight/; Cursor also loads .agents/skills and Claude/Codex skill folders. Invoke with /seo-preflight in Agent chat. Cursor skills docs.
Or download SKILL.md and place it at <skills-folder>/seo-preflight/SKILL.md — the folder name must match the name in the frontmatter.
How to use it
After installing, restart your agent and ask in plain language — the agent loads the skill when the request matches its description:
Run seo-preflight on ./out before I deployAudit this Next.js site for SEO and fix all P0 and P1 issuesCheck our sitemap, robots.txt and llms.txt and set up IndexNow
The full SKILL.md
192 lines · 14 KB · raw file
---
name: seo-preflight
description: Pre-launch SEO and GEO (AI-search) audit for a static or Next.js site. Checks titles, meta descriptions, canonicals, Open Graph, JSON-LD validity, one H1 per page, broken internal links, sitemap.xml, robots.txt, llms.txt, IndexNow and answer-first copy, then outputs a prioritized P0-P3 fix list and can apply the fixes. Use before deploying a site, after adding pages, or when asked for an SEO audit, SEO check, or launch checklist.
---
# SEO preflight
Audit a website build **before it ships** and leave it in a state where search engines and AI answer engines can crawl, understand and cite it. Works on any folder of built HTML (plain static sites, Astro/Hugo/Eleventy output, `next build && next export` / `out/`, or a Next.js `.next` app via the running server).
Source: https://getaicodingagent.com/skills/seo-preflight/ — this exact workflow gates every deploy of getaicodingagent.com (60+ pages, 0 problems required before deploy).
## Inputs to establish first
1. **Build output folder** — the HTML that will actually be served (e.g. `dist/`, `out/`, `public/`, `build/`, `_site/`). Do not audit source templates. If no build exists, run the project's build command first (`npm run build`, etc.). For a Next.js app without static export, start it (`npm run build && npm start`) and save the rendered pages you care about with `curl -s http://localhost:3000/<path> > /tmp/seo-site/<path>/index.html`, plus `/sitemap.xml` and `/robots.txt`.
2. **Production origin**, e.g. `https://example.com` (no trailing slash). Ask the user if it is not in the config (`next.config.*`, `astro.config.*`, `siteUrl`, `metadataBase`, `.env`).
3. **Mode**: report-only (default) or **apply fixes** (only if the user asked to fix, or confirms after seeing the report).
## Step 1 — Run the automated checks
Save the script below to a temp file (e.g. `/tmp/seo_preflight.py`) and run `python3 /tmp/seo_preflight.py <build-folder> <https://origin>`. It needs only Python 3 (no packages). Exit code 1 means P0/P1 issues exist.
```python
#!/usr/bin/env python3
"""seo-preflight checker: python3 seo_preflight.py <built-site-dir> [https://your-domain]"""
import os, re, sys, json, html as H
from urllib.parse import urlparse
ROOT = sys.argv[1]; DOMAIN = (sys.argv[2] if len(sys.argv) > 2 else "").rstrip("/")
F = []
def add(p, rule, f, d=""): F.append((p, rule, f, d))
pages = [os.path.relpath(os.path.join(d, f), ROOT) for d, dirs, fs in os.walk(ROOT)
for f in fs if f.endswith(".html") and not any(x.startswith((".", "_")) or x == "node_modules" for x in os.path.relpath(d, ROOT).split(os.sep) if x != ".")]
def url_of(f):
u = "/" + f.replace(os.sep, "/")
u = u[:-10] if u.endswith("/index.html") or u == "/index.html" else u
if u == "": u = "/"
return u if u.endswith("/") else (u[:-5] if u.endswith(".html") else u)
def exists(p):
p = p.split("#")[0].split("?")[0]
if p in ("", "/"): return True
fp = os.path.join(ROOT, p.lstrip("/"))
return os.path.isfile(fp) or os.path.isfile(fp + ".html") or os.path.isfile(os.path.join(fp, "index.html"))
def meta(s, attr, val):
m = re.search(r'<meta[^>]+%s=["\']%s["\'][^>]*>' % (attr, re.escape(val)), s, re.I)
if not m: return None
c = re.search(r'content=["\']([^"\']*)', m.group(0), re.I)
return H.unescape(c.group(1)) if c else ""
titles, descs, urls = {}, {}, set()
for f in sorted(pages):
s = open(os.path.join(ROOT, f), encoding="utf-8", errors="replace").read()
noindex = re.search(r'<meta[^>]+name=["\']robots["\'][^>]+noindex', s, re.I)
if not noindex: urls.add(url_of(f))
t = re.search(r"<title[^>]*>(.*?)</title>", s, re.S | re.I)
t = H.unescape(t.group(1).strip()) if t else ""
if not t: add("P0", "missing-title", f)
else:
titles.setdefault(t, []).append(f)
if len(t) > 65: add("P2", "title-too-long", f, f"{len(t)} chars: {t}")
if len(t) < 15: add("P2", "title-too-short", f, t)
d = meta(s, "name", "description")
if not d: add("P1", "missing-meta-description", f)
else:
descs.setdefault(d, []).append(f)
if not noindex and not 70 <= len(d) <= 165: add("P2", "description-length", f, f"{len(d)} chars")
can = re.search(r'<link[^>]+rel=["\']canonical["\'][^>]*href=["\']([^"\']+)', s, re.I) or re.search(r'<link[^>]+href=["\']([^"\']+)["\'][^>]*rel=["\']canonical', s, re.I)
if not can and not noindex: add("P1", "missing-canonical", f)
elif can:
cu = can.group(1)
if not cu.startswith("http"): add("P1", "canonical-not-absolute", f, cu)
elif DOMAIN and not cu.startswith(DOMAIN): add("P1", "canonical-wrong-host", f, cu)
elif DOMAIN and urlparse(cu).path.rstrip("/") != url_of(f).rstrip("/") and not noindex: add("P2", "canonical-points-elsewhere", f, cu)
for og in ("og:title", "og:description", "og:url"):
if meta(s, "property", og) is None: add("P2", "missing-" + og.replace(":", "-"), f)
if meta(s, "property", "og:image") is None: add("P3", "missing-og-image", f)
if not re.search(r"<html[^>]+lang=", s, re.I): add("P2", "missing-html-lang", f)
if not re.search(r'<meta[^>]+name=["\']viewport', s, re.I): add("P1", "missing-viewport", f)
h1 = len(re.findall(r"<h1[\s>]", s, re.I))
if h1 == 0: add("P1", "no-h1", f)
if h1 > 1: add("P1", "multiple-h1", f, f"{h1} h1 tags")
for m in re.finditer(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', s, re.S | re.I):
try:
j = json.loads(m.group(1))
for o in (j if isinstance(j, list) else j.get("@graph", [j])):
if isinstance(o, dict) and "@type" not in o: add("P1", "jsonld-missing-type", f)
if isinstance(o, dict) and o.get("@type") == "FAQPage":
for q in o.get("mainEntity", []):
if q.get("name") and q["name"] not in H.unescape(re.sub(r"<[^>]+>", " ", s)): add("P2", "faq-jsonld-not-visible", f, q["name"][:60])
except Exception as ex: add("P0", "invalid-jsonld", f, str(ex)[:80])
for m in re.finditer(r'<img\b[^>]*>', s, re.I):
if not re.search(r'\balt=', m.group(0), re.I): add("P2", "img-missing-alt", f, m.group(0)[:80])
for m in re.finditer(r'''(?:href|src)=["']([^"']+)["']''', s):
u = m.group(1)
if u.startswith(("mailto:", "tel:", "data:", "#", "javascript:", "//")): continue
if u.startswith("http"):
if DOMAIN and u.startswith(DOMAIN): u = u[len(DOMAIN):] or "/"
else: continue
if not u.startswith("/"): u = "/" + os.path.normpath(os.path.join(os.path.dirname(f), u)).replace(os.sep, "/")
if not exists(u): add("P0", "broken-internal-link", f, u)
body = re.sub(r"<(script|style|nav|header|footer)\b.*?</\1>", " ", s, flags=re.S | re.I)
words = len(re.sub(r"<[^>]+>", " ", body).split())
if words < 250 and not noindex: add("P2", "thin-content", f, f"~{words} words")
first_p = re.search(r"<p\b[^>]*>(.*?)</p>", body, re.S | re.I)
if first_p and len(re.sub(r"<[^>]+>", "", first_p.group(1)).split()) > 80: add("P3", "answer-not-first", f, "first paragraph > 80 words")
for t, fs in titles.items():
if len(fs) > 1: add("P1", "duplicate-title", ", ".join(fs[:4]), t)
for d, fs in descs.items():
if len(fs) > 1: add("P2", "duplicate-description", ", ".join(fs[:4]), d[:60])
sm = os.path.join(ROOT, "sitemap.xml")
if not os.path.isfile(sm): add("P0", "missing-sitemap", "sitemap.xml")
else:
locs = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", open(sm).read())
paths = set()
for l in locs:
p = urlparse(l).path or "/"
paths.add(p.rstrip("/") or "/")
if not exists(p): add("P0", "sitemap-url-missing-file", "sitemap.xml", l)
if DOMAIN and not l.startswith(DOMAIN): add("P1", "sitemap-wrong-host", "sitemap.xml", l)
for u in sorted(urls):
if (u.rstrip("/") or "/") not in paths and u not in ("/404", "/404.html"): add("P1", "page-not-in-sitemap", u)
rb = os.path.join(ROOT, "robots.txt")
if not os.path.isfile(rb): add("P1", "missing-robots-txt", "robots.txt")
else:
r = open(rb).read()
if re.search(r"(?im)^disallow:\s*/\s*$", r): add("P0", "robots-blocks-everything", "robots.txt")
if "sitemap:" not in r.lower(): add("P2", "robots-no-sitemap-line", "robots.txt")
if not os.path.isfile(os.path.join(ROOT, "llms.txt")): add("P2", "missing-llms-txt", "llms.txt")
keys = [f for f in os.listdir(ROOT) if re.fullmatch(r"[a-f0-9]{8,128}\.txt", f) and open(os.path.join(ROOT, f)).read().strip() == f[:-4]]
if not keys: add("P3", "no-indexnow-key-file", "/", "optional: enables IndexNow pings to Bing/Yandex")
order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
F.sort(key=lambda x: (order[x[0]], x[1], x[2]))
for p, rule, f, d in F: print(f"{p}\t{rule}\t{f}\t{d}")
c = {k: sum(1 for x in F if x[0] == k) for k in order}
print(f"\nSUMMARY pages={len(pages)} indexable={len(urls)} " + " ".join(f"{k}={v}" for k, v in c.items()) + (f" indexnow_key={keys[0]}" if keys else ""))
sys.exit(1 if c["P0"] or c["P1"] else 0)
```
Notes on the script: it treats `/x`, `/x.html` and `/x/index.html` as the same URL (clean-URL hosts); external links are not fetched (see Step 3); hidden folders and `_`-prefixed folders are skipped.
## Step 2 — Manual checks the script cannot judge
Open the 3–5 most important pages (home, top landing pages, newest pages) and check:
- **Title**: unique, ≤ 60 characters, primary keyword first, matches search intent ("X vs Y: …", "Best … (2026)", "What is …"). No brand-only titles on inner pages.
- **Meta description**: 120–160 characters, states the concrete answer/benefit, not "Welcome to…".
- **Answer-first copy (GEO)**: the first paragraph under the H1 answers the page's main question in 1–2 sentences with specific facts (numbers, dates, names). AI answer engines quote this paragraph. Facts must be sourced (outbound link to the primary source).
- **Headings**: exactly one H1 that matches the title topic; H2s phrased as the questions people search.
- **JSON-LD matches the page**: type fits the content (Article/NewsArticle for news, FAQPage only when the Q&A is visible on the page, SoftwareApplication/Product with real prices, BreadcrumbList on inner pages). Never mark up content that isn't visible.
- **Internal links**: every new page is linked from at least one existing, crawled page (home, hub or related pages) with descriptive anchor text — not only from the sitemap.
- **llms.txt** (https://llmstxt.org): a `# Site name`, a `>` one-line summary, then sections of `- [Title](absolute URL): description` for the key pages. Make sure new key pages are listed.
- **robots.txt**: does not block CSS/JS or important paths; has `Sitemap: https://origin/sitemap.xml`; blocks only private/build paths.
- **Next.js specifics**: `metadataBase` set (otherwise canonicals/OG URLs come out relative or `localhost`), `app/sitemap.ts` and `app/robots.ts` include every route, `generateMetadata` gives each dynamic route a unique title, no accidental `noindex` from preview env vars.
## Step 3 — Live checks (only if the site is already deployed)
```bash
ORIGIN=https://example.com
curl -s "$ORIGIN/sitemap.xml" | grep -o '<loc>[^<]*' | sed 's/<loc>//' | while read u; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"; done | grep -v '^200' || echo "all sitemap URLs 200"
curl -sI "$ORIGIN/robots.txt" | head -1; curl -sI "$ORIGIN/llms.txt" | head -1
```
Redirect chains (301/307 → 200) in the sitemap are P2: list the final URL instead.
## Step 4 — Output the report
Reply with exactly this structure (keep it short, group repeated issues):
```
## SEO preflight — <origin> — <date>
Result: <READY | NOT READY> (P0=<n> P1=<n> P2=<n> P3=<n>, <pages> pages)
### P0 — blocks launch (broken pages, invalid JSON-LD, robots blocking, missing sitemap)
- [rule] file/URL — what's wrong → exact fix
### P1 — fix before launch (missing title/description/canonical, duplicate titles, H1 problems, pages not in sitemap)
### P2 — fix this week (lengths, OG tags, thin pages, llms.txt, alt text)
### P3 — nice to have (og:image, IndexNow key, answer-first tweaks)
### Top 3 changes with the biggest impact
1. …
```
"READY" only when P0 = 0 and P1 = 0.
## Step 5 — Apply fixes (only in fix mode)
- Fix at the **source** (templates, layout, `generateMetadata`, data files, sitemap generator) — never hand-edit generated HTML that the next build will overwrite.
- One logical change at a time; rebuild; re-run Step 1 until P0 = P1 = 0. Do not change URLs of already-indexed pages; if you must, add a 301.
- Do not invent facts, prices or reviews to "fix" thin content — flag it for the user instead.
- If the user wants IndexNow (Bing, Yandex, Seznam, Naver; Google does not use it): generate a key (`python3 -c "import secrets;print(secrets.token_hex(16))"`), publish `/<key>.txt` containing the key, deploy, then
`curl -s -X POST https://api.indexnow.org/indexnow -H 'Content-Type: application/json' -d '{"host":"example.com","key":"<key>","keyLocation":"https://example.com/<key>.txt","urlList":["https://example.com/new-page"]}' -w '%{http_code}\n'` (200/202 = accepted).
- After deploy: resubmit the sitemap in Google Search Console and re-run Step 3.
Finish by restating the result line and the remaining open items.
FAQ
What is the best Claude Code skill for SEO?
Does the SEO audit skill work with Next.js?
Does it need any packages or an API key?
Will the agent change my code without asking?
What is GEO and how does the skill check it?
More free skills
- Trend Landing Page — Trend Landing Page Skill for Claude Code, Codex & Cursor
- AI Tool Pricing Check — AI Tool Pricing Check Skill for Claude Code, Codex, Cursor