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