SEO Preflight: the SEO audit Agent Skill for Claude Code, Codex & Cursor

Free SKILL.md · works in Claude Code, OpenAI Codex and Cursor · tested on getaicodingagent.com

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"; done

Installs 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.md

Personal 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.md

User-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.md

Run 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 deploy
  • Audit this Next.js site for SEO and fix all P0 and P1 issues
  • Check 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?
seo-preflight is a free Claude Code skill built for exactly that: it audits your built site for technical SEO and GEO (AI search) problems, ranks them P0–P3 and can fix them. It also works unchanged in OpenAI Codex and Cursor because all three read the same SKILL.md format.
Does the SEO audit skill work with Next.js?
Yes. Point it at a static export (out/) or let the agent start the production server and save rendered pages. The skill also checks Next.js-specific pitfalls such as a missing metadataBase, which produces relative or localhost canonical and Open Graph URLs.
Does it need any packages or an API key?
No. The checker is plain Python 3 with no dependencies, embedded in the SKILL.md. Live checks use curl. No SEO SaaS account is needed.
Will the agent change my code without asking?
No. The default mode is report-only. The skill applies fixes only when you ask it to, and it fixes at the source (templates, metadata, data) instead of hand-editing generated HTML.
What is GEO and how does the skill check it?
GEO (generative engine optimization) means making pages easy for AI answer engines to quote. The skill checks for an answer-first opening paragraph with sourced facts, visible FAQ content that matches FAQPage JSON-LD, and an llms.txt file listing your key pages.

More free skills