# Auditing and optimizing a site for SEO, AEO, GEO, and AI search

A practical guide to running a real audit on a personal or product site, parsing the findings, and shipping the fixes that move the score and the user experience together. Based on the implementation behind [agnelnieves.com](https://agnelnieves.com) (Next.js 16, App Router, deployed on Vercel) but the concepts apply to any framework.

The narrative companion is the blog post [Optimizing a personal site for SEO, AEO, GEO, and AI search in 2026](/blog/optimizing-a-personal-site-for-ai-search-in-2026), which has the story and the numbers. This guide is the executable version.

---

## Use this guide with an AI agent

Paste this guide into your AI coding agent (Claude, Cursor, etc.) along with the prompt below. The prompt is the load bearing piece. It tells the agent how to adapt the guide to your project and what to ask you before making changes.

> I want to audit and optimize my Next.js site for SEO, AEO, GEO, and AI search engines (AI Overviews, Perplexity, Copilot, ChatGPT search). Use the attached guide as your reference playbook. Adapt it to my stack: if my site is not Next.js or not on Vercel, tell me what to swap. Walk me through each step in order: (1) Google Search Console verification via DNS TXT, (2) Bing Webmaster Tools and IndexNow ping, (3) a baseline Lighthouse audit via the Node CLI, (4) image optimization with sharp, (5) audio optimization with ffmpeg, (6) deferring heavy assets to first interaction, (7) modernizing the browserslist to drop polyfills, (8) finding and fixing common accessibility regressions, (9) making the LCP element a priority image, (10) a final re-audit. Stop and ask me for the details you need (domain, registrar, DNS provider, framework, hosting, where my heavy assets live, what my baseline Lighthouse scores show) before generating commands. At the end, give me one re-audit command I can run to verify the numbers moved.

---

## Table of Contents

- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [1. Verify ownership in Google Search Console](#1-verify-ownership-in-google-search-console)
- [2. Set up Bing Webmaster Tools and IndexNow](#2-set-up-bing-webmaster-tools-and-indexnow)
- [3. Run a baseline Lighthouse audit](#3-run-a-baseline-lighthouse-audit)
- [4. Parse the JSON output for findings](#4-parse-the-json-output-for-findings)
- [5. Optimize images with sharp and AVIF](#5-optimize-images-with-sharp-and-avif)
- [6. Optimize audio with ffmpeg](#6-optimize-audio-with-ffmpeg)
- [7. Defer heavy assets to first interaction](#7-defer-heavy-assets-to-first-interaction)
- [8. Modernize the browserslist](#8-modernize-the-browserslist)
- [9. Fix common accessibility regressions](#9-fix-common-accessibility-regressions)
- [10. Make the LCP element a priority image](#10-make-the-lcp-element-a-priority-image)
- [11. Re-audit and verify](#11-re-audit-and-verify)
- [Complete Checklist](#complete-checklist)
- [Verification](#verification)
- [Resources](#resources)

---

## Overview

Two principles drive every decision in this guide:

1. **Classic SEO is the foundation.** Google's [AI optimization guide](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide) is explicit: AI Overviews and AI Mode rank from the same index as Search. There is no AI specific markup that changes anything. Fast, indexable, structured pages are the goal.
2. **Measure, then move.** Lighthouse via the Node CLI produces a JSON report with concrete findings. Fix what the report says, in priority order. Don't optimize on intuition.

You can complete a full pass in an afternoon for a personal site. The big wins are usually one or two heavy assets that should not load eagerly, plus a handful of accessibility regressions that drag two categories at once.

---

## Prerequisites

- **A site you control** with a verifiable domain.
- **Node.js 18+ or Bun** for running Lighthouse and conversion scripts.
- **Chrome installed** so Lighthouse can launch headless. On macOS it's at `/Applications/Google Chrome.app`.
- **`ffmpeg`** if you have audio assets to re-encode (`brew install ffmpeg` on macOS).
- **`dig`** for verifying DNS records (preinstalled on macOS and most Linux distros).
- **A Google account** for Search Console and a Microsoft account for Bing Webmaster Tools.
- **Access to your domain's DNS provider** for TXT verification records.

---

## 1. Verify ownership in Google Search Console

Google's guide flags Search Console verification as the one explicit must do. Without it, you can't see AI Overviews / AI Mode performance reports, indexing coverage, or query data.

### Pick the property type

- **Domain property** (recommended). Verified via DNS TXT. Covers apex, www, http, https, and every subdomain in one shot.
- **URL prefix property**. Verified via meta tag, HTML file, or DNS. Only covers one origin (e.g., `https://example.com/` versus `https://www.example.com/`).

Go with Domain property unless you have a specific reason for URL prefix.

### Add the TXT record

1. In Search Console, choose **Add property** → **Domain** → enter your bare domain (no scheme, no www).
2. Google shows a `google-site-verification=<token>` string. Copy it.
3. In your DNS provider (Vercel, Cloudflare, Namecheap, registrar control panel), add a TXT record:
   - **Name / Host:** apex (leave blank in Vercel; `@` in most registrars).
   - **Value:** `google-site-verification=<token>` (the entire string, no quotes).
   - **TTL:** 60 seconds is fine.

Common gotcha: your domain's nameservers may not match the DNS panel you're editing. If you used to host elsewhere, the registrar's panel can be a dead config. Verify what's actually live:

```bash
dig +short NS yoursite.com
dig +short TXT yoursite.com | grep google-site-verification
```

The first tells you which nameservers are authoritative. The second confirms your record is reachable. If the second is empty after a few minutes, you're editing the wrong DNS panel.

### Click Verify

Once `dig` returns your token, hit Verify in Search Console. Verification is permanent until you remove the TXT record.

### Submit your sitemap

After verification, go to **Sitemaps** in the left sidebar and submit `sitemap.xml`. This is the single most useful follow up and gets your URLs into Google's indexing queue immediately.

---

## 2. Set up Bing Webmaster Tools and IndexNow

Google does not participate in IndexNow. Bing and Yandex do, and Bing's index powers ChatGPT's web search, Copilot, and DuckDuckGo. For a site that publishes new content occasionally, IndexNow shaves the indexing latency from days to minutes.

### Sign in and add the site

Go to [bing.com/webmasters](https://www.bing.com/webmasters), add your site, and verify via DNS TXT the same way you did for Google. Bing offers an "Import from Google Search Console" shortcut that auto verifies if your Google account matches.

### Generate an IndexNow key

The key is a 32 character hex string that you make up. Save it at the apex of your site so api.indexnow.org can verify ownership:

```
public/<your-32-char-key>.txt
```

The file content is literally just the key string. Nothing else. Once deployed, it must be reachable at `https://yoursite.com/<your-key>.txt`.

### Add the ping script

Create `scripts/indexnow.mjs`:

```javascript
#!/usr/bin/env node
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";

const SITE_URL = "https://yoursite.com";
const HOST = "yoursite.com";
const KEY = "<your-32-char-key>";
const KEY_LOCATION = `${SITE_URL}/${KEY}.txt`;
const ENDPOINT = "https://api.indexnow.org/IndexNow";
const BLOG_DIR = "src/content/blog";

const before = process.env.BEFORE_SHA ?? "";
const after = process.env.AFTER_SHA || "HEAD";
const hasBefore = before && !/^0+$/.test(before);
const range = hasBefore ? `${before}..${after}` : `${after}~1..${after}`;

function changedBlogFiles() {
  const out = execSync(
    `git diff --name-only --diff-filter=AM ${range} -- '${BLOG_DIR}/*.md' '${BLOG_DIR}/*.mdx'`,
    { encoding: "utf8" },
  );
  return out.split("\n").map((s) => s.trim()).filter(Boolean);
}

function isPublished(filePath) {
  const content = readFileSync(filePath, "utf8");
  const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
  if (!fm) return false;
  const statusLine = fm[1].split(/\r?\n/).find((l) => /^status\s*:/.test(l));
  return statusLine ? /published/.test(statusLine) : false;
}

function fileToUrl(filePath) {
  const slug = path.basename(filePath).replace(/\.(md|mdx)$/, "");
  return `${SITE_URL}/blog/${slug}`;
}

const changed = changedBlogFiles();
const urls = changed.filter(isPublished).map(fileToUrl);
if (urls.length === 0) {
  console.log("No published blog changes. Skipping.");
  process.exit(0);
}

const res = await fetch(ENDPOINT, {
  method: "POST",
  headers: { "Content-Type": "application/json; charset=utf-8" },
  body: JSON.stringify({ host: HOST, key: KEY, keyLocation: KEY_LOCATION, urlList: urls }),
});

console.log(`IndexNow responded ${res.status} ${res.statusText}`);
process.exit(res.status === 200 || res.status === 202 ? 0 : 1);
```

### Add the GitHub Action

Create `.github/workflows/indexnow.yml`. The `paths` filter is the trick that keeps it from firing on code only pushes:

```yaml
name: IndexNow ping

on:
  push:
    branches: [main]
    paths:
      - 'src/content/blog/**'

permissions:
  contents: read

jobs:
  ping:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
      - name: Notify IndexNow
        env:
          BEFORE_SHA: ${{ github.event.before }}
          AFTER_SHA: ${{ github.event.after }}
        run: node scripts/indexnow.mjs
```

### Test it locally

Run against a recent commit that touched a blog post:

```bash
BEFORE_SHA=$(git rev-parse HEAD~1) AFTER_SHA=$(git rev-parse HEAD) \
  node scripts/indexnow.mjs
```

You should see `IndexNow responded 202 Accepted`. 202 means "received, key validation pending." Once your key file is reachable in production, future pings will return 200.

---

## 3. Run a baseline Lighthouse audit

Use the Node CLI so you get the JSON output. The web extension is fine for spot checks but harder to parse.

```bash
mkdir -p /tmp/lighthouse
bunx --bun lighthouse@latest https://yoursite.com/ \
  --output=json \
  --output-path=/tmp/lighthouse/home-mobile.json \
  --quiet \
  --chrome-flags="--headless=new --no-sandbox" \
  --form-factor=mobile
```

Run it for the homepage and one representative inner page (a blog post is usually best). Mobile is what Google uses for ranking. Desktop scores will be higher but less actionable.

Each audit takes 60 to 90 seconds. Run them sequentially; parallel Chrome instances skew the measurements.

---

## 4. Parse the JSON output for findings

Lighthouse's JSON has the data you need without opening the HTML report. Pull the headline scores first:

```bash
jq '.categories | to_entries | map({key, score: .value.score})' \
  /tmp/lighthouse/home-mobile.json
```

Then the core web vitals:

```bash
jq -r '
{
  FCP: .audits["first-contentful-paint"].displayValue,
  LCP: .audits["largest-contentful-paint"].displayValue,
  TBT: .audits["total-blocking-time"].displayValue,
  CLS: .audits["cumulative-layout-shift"].displayValue,
  SI: .audits["speed-index"].displayValue,
  byteWeight: .audits["total-byte-weight"].displayValue
}' /tmp/lighthouse/home-mobile.json
```

If LCP is high and total weight is large, list the heaviest network requests:

```bash
jq -r '.audits["network-requests"].details.items
  | sort_by(-(.transferSize // 0))
  | .[0:15]
  | map({
      url: (.url | if length > 80 then .[0:77] + "..." else . end),
      sizeKB: (((.transferSize // 0) / 1024) | floor),
      mime: .mimeType
  })' /tmp/lighthouse/home-mobile.json
```

The smoking gun is usually obvious. For a personal site, it's typically one or two of: large unoptimized images, an autoplaying video, a heavy font, an analytics or chat widget loaded synchronously, or background music.

---

## 5. Optimize images with sharp and AVIF

If your heaviest requests are images, AVIF is the right target. Roughly 95% browser support, much better compression than JPEG or WebP.

### Install sharp via bun

```bash
bun add -d sharp
```

Sharp ships its native binary through `@img/sharp-*` optional dependencies, not a postinstall script, so it works with `bunfig.toml` settings that block postinstalls. If you're using npm or pnpm, the postinstall is needed; check your trusted dependencies list.

### The conversion script

Create `scripts/convert-to-avif.mjs`:

```javascript
#!/usr/bin/env bun
import { promises as fs } from "node:fs";
import path from "node:path";
import sharp from "sharp";

const ROOT = path.resolve(import.meta.dir, "..");

const TARGETS = [
  { dir: "public/images/photos", width: 400, quality: 50, removeOriginal: true },
  { dir: "public/images/projects", width: 800, quality: 52, removeOriginal: true },
];

for (const target of TARGETS) {
  const absDir = path.join(ROOT, target.dir);
  const entries = await fs.readdir(absDir).catch(() => []);
  const sources = entries.filter((f) => /\.(jpe?g|png)$/i.test(f));

  for (const file of sources) {
    const inputPath = path.join(absDir, file);
    const outputPath = inputPath.replace(/\.(jpe?g|png)$/i, ".avif");
    const before = await fs.stat(inputPath);

    await sharp(inputPath)
      .rotate()
      .resize({ width: target.width, withoutEnlargement: true })
      .avif({ quality: target.quality, effort: 6, chromaSubsampling: "4:2:0" })
      .toFile(outputPath);

    const after = await fs.stat(outputPath);
    console.log(`${file}: ${(before.size/1024).toFixed(0)} KB → ${(after.size/1024).toFixed(0)} KB`);

    if (target.removeOriginal) {
      await fs.unlink(inputPath);
    }
  }
}
```

### Pick the right target width

This is the single most important choice. Lighthouse's "Improve image delivery" audit reports the displayed dimensions for each image:

```
This image file is larger than it needs to be (800x800) for its displayed
dimensions (395x388). Use responsive images to reduce the image download size.
```

Target width should roughly match the displayed dimensions for 1x scoring, or 1.5x to 2x for retina coverage. Lighthouse measures at 1x, so anything beyond that is "wasted bytes" in the score. For decorative images that go through a downsampling pipeline (like a dither effect), 1x is fine.

### Update the source paths

After conversion, grep for the old `.jpg` or `.png` paths and replace with `.avif`:

```bash
grep -rn '\.jpg\|\.png' src/ --include='*.ts' --include='*.tsx' \
  | grep -v 'node_modules'
```

Update each reference, then run the build to catch broken paths.

### A note on aspect ratios

`sharp.resize({ width })` preserves the source's aspect ratio. If your source PNG had different aspect than what you were rendering it as (CSS `width=X height=Y`), the AVIF will have different intrinsic dimensions than the CSS dimensions, and Next/Image will warn about it. Fix by either matching the props to the natural dimensions and using CSS `h-auto` or `w-auto`, or by cropping during conversion.

---

## 6. Optimize audio with ffmpeg

If you have any audio assets, check the bitrate and channels:

```bash
ffprobe -v error -show_entries stream=codec_name,bit_rate,sample_rate,channels \
  -of default=noprint_wrappers=1 "public/sounds/music.mp3"
```

Most music or sound effects on a portfolio site are over engineered. 187 kbps stereo for a background loop that plays at 2.5% volume is a real example, and 64 kbps mono sounds identical.

### Re-encode

```bash
ffmpeg -y -i "public/sounds/music.mp3" \
  -vn -map_metadata -1 \
  -ac 1 -ar 44100 -b:a 64k -codec:a libmp3lame \
  "public/sounds/music.optimized.mp3"
```

Flags:
- `-vn` strips embedded cover art (which can be hundreds of KB of JPEG riding inside the MP3).
- `-map_metadata -1` drops ID3 tags.
- `-ac 1` mono, `-ar 44100` 44.1 kHz, `-b:a 64k` 64 kbps.

Verify the new file:

```bash
ffprobe -v error -show_entries stream=bit_rate -of default=noprint_wrappers=1 \
  "public/sounds/music.optimized.mp3"
```

Then `mv music.optimized.mp3 music.mp3` to replace in place.

For short UI sound effects (click, hover, etc.), the file is usually already small enough. Skip unless the budget genuinely matters.

---

## 7. Defer heavy assets to first interaction

Even after re-encoding, audio (and large videos) should not load on page mount. Defer them until the user actually interacts.

A common antipattern in React:

```tsx
// BAD: preloads the file on every mount, even if the user never clicks.
useEffect(() => {
  const audio = new Audio("/sounds/music.mp3");
  audioRef.current = audio;
}, []);
```

The fix:

```tsx
// GOOD: only constructs the Audio object after the first user click.
const ensureAudio = () => {
  if (!audioRef.current) {
    audioRef.current = new Audio("/sounds/music.mp3");
    audioRef.current.loop = true;
    audioRef.current.volume = 0.025;
  }
  return audioRef.current;
};

const togglePlay = () => {
  const audio = ensureAudio();
  audio.play().catch(() => {});
};
```

Same pattern for any third party widget (chat, embed, video player). The page should never pay the byte cost of a feature the user hasn't asked for.

---

## 8. Modernize the browserslist

Lighthouse's "Legacy JavaScript" insight flags polyfills your bundle ships for older browsers. The most common offender is `Array.prototype.at`, which Safari only got in 15.4 (March 2022). If your browserslist allows older Safari, your bundle includes a polyfill.

Add an explicit browserslist field to `package.json`:

```json
{
  "browserslist": [
    "chrome >= 92",
    "firefox >= 90",
    "safari >= 15.4",
    "edge >= 92",
    "not dead"
  ]
}
```

Those versions all ship with `.at()`, structured clone, top level await in modules, and most of the post 2021 features the polyfills are written for. Saves about 14 KB on a typical Next.js bundle.

Rebuild and verify the new bundle no longer contains the polyfill:

```bash
bun run build
grep -r 'Array.prototype.at' .next/static/chunks/ | head
```

The polyfill is gone if `grep` returns nothing.

---

## 9. Fix common accessibility regressions

A11y issues often cascade into the new "Agentic Browsing" category because Lighthouse uses the same accessibility tree to evaluate AI agent compatibility.

### Logo links without an accessible name

The most common single regression:

```tsx
// BAD: SVG-only link has no accessible name.
<Link href="/"><Logo /></Link>

// GOOD
<Link href="/" aria-label="Home"><Logo /></Link>
```

This one fix routinely lifts both the Accessibility and Agentic Browsing scores by 30+ points combined.

### Multiple h1 tags on a page

Find them all:

```bash
grep -rn '<h1' --include='*.tsx' src/
```

Every page should have exactly one h1. Common offenders:

- Cards in a listing using h1 instead of h2 or h3.
- Slide components in a deck where the page already has an h1.
- An MDX components mapping that renders markdown `#` as h1. Demote it to h2 to prevent regressions from new content.

### `<img alt="">` with the description on an ARIA wrapper

```tsx
// Looks correct to screen readers, but strict SEO scanners flag the empty alt.
<div role="img" aria-label={alt}>
  <img alt="" src={src} />
</div>

// Better: put alt on the img directly.
<div>
  <img alt={alt} src={src} />
</div>
```

### Tables missing header rows

Markdown tables generated by remark-gfm only get `<thead>` if you include the separator row (`| --- | --- |`). Empty first header cells (`| | Old | New |`) also fail axe's `td-has-header` check. Give every column a non-empty header label.

---

## 10. Make the LCP element a priority image

After the asset fixes, the LCP is usually whatever image renders first in the viewport. Lighthouse will flag it as `lazy load not applied` and `fetchpriority=high should be applied`. The fix is one prop in Next/Image:

```tsx
<Image
  priority
  src="/images/hero.avif"
  alt="..."
  width={1200}
  height={630}
/>
```

`priority` does three things:

- Sets `loading="eager"` so the browser doesn't wait.
- Sets `fetchpriority="high"`.
- Emits a `<link rel="preload">` in the HTML head.

Only mark the actual LCP element. If you mark too many images priority, the browser deprioritizes them all and you get nothing.

---

## 11. Re-audit and verify

After your changes are deployed, wait for the deploy to go live (poll a known new asset):

```bash
until curl -sfI -o /dev/null https://yoursite.com/<new-asset>; do sleep 5; done
echo "deploy live"
```

Then re-run Lighthouse and diff the scores:

```bash
bunx --bun lighthouse@latest https://yoursite.com/ \
  --output=json \
  --output-path=/tmp/lighthouse/home-mobile-after.json \
  --quiet \
  --chrome-flags="--headless=new --no-sandbox" \
  --form-factor=mobile

jq '.categories | to_entries | map({key, score: .value.score})' \
  /tmp/lighthouse/home-mobile-after.json
```

Compare to your baseline. If a category dropped, look at the new failing audits with the same JSON queries from step 4.

---

## Complete Checklist

- [ ] Google Search Console verified via DNS TXT (Domain property).
- [ ] Sitemap submitted in Search Console.
- [ ] Bing Webmaster Tools verified.
- [ ] IndexNow key file at `public/<key>.txt` and reachable in production.
- [ ] `scripts/indexnow.mjs` ping script in place.
- [ ] `.github/workflows/indexnow.yml` triggers only on blog content changes.
- [ ] Baseline Lighthouse audit captured for homepage and one inner page.
- [ ] Heaviest images converted to AVIF at correct display dimensions.
- [ ] Audio re-encoded if applicable.
- [ ] Heavy assets lazy initialized on first user interaction.
- [ ] `browserslist` field in `package.json` targets modern browsers.
- [ ] All pages have exactly one h1.
- [ ] All `<img>` tags have non empty alt text or are explicitly decorative.
- [ ] All icon only links have `aria-label`.
- [ ] LCP element has `priority` set in Next/Image.
- [ ] Dead third party stylesheets and scripts removed.
- [ ] Re-audit shows the deltas you expected.

---

## Verification

The end state for a small site looks roughly like this on mobile, homepage:

```
Performance       ≥ 70
Accessibility     = 100
Best Practices    = 100
SEO               = 100
Agentic Browsing  = 100

LCP   < 6 s
FCP   < 3 s
TBT   < 200 ms
CLS   < 0.1
Total page weight  ≤ 1.5 MB
```

If you're not there yet, the typical remaining bottleneck is one of: a heavy third party script (analytics, chat widget), a non-priority hero image, a render-blocking external stylesheet, or a font config that ships variable fonts at full weight when you only use two cuts.

Re-audit, find the heaviest remaining offender, fix it, re-audit again.

---

## Resources

- [Google: Optimizing your website for generative AI features on Google Search](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)
- [Google Search Central: Search Console](https://search.google.com/search-console)
- [Bing Webmaster Tools](https://www.bing.com/webmasters)
- [IndexNow specification](https://www.indexnow.org/documentation)
- [Lighthouse Node CLI](https://github.com/GoogleChrome/lighthouse#using-the-node-cli)
- [sharp documentation](https://sharp.pixelplumbing.com/)
- [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html)
- [Next.js Image component](https://nextjs.org/docs/app/api-reference/components/image)
- [browserslist syntax](https://github.com/browserslist/browserslist)
- [Web.dev: Optimize LCP](https://web.dev/articles/optimize-lcp)
- [llms.txt standard](https://llmstxt.org) (note: Google has confirmed it does not use this; other engines may)
