# The AEO / GEO Playbook

**How to build a website that AI assistants can read, trust, and cite.**

Written by the Plank team from the patterns we run on [joinplank.com](https://joinplank.com).
Every code sample here is production code from that site, generalised so you can drop it into yours.

- **Stack in the examples:** Next.js App Router + TypeScript + Tailwind. The *principles* are framework-agnostic; only the syntax is Next-specific.
- **Reading time:** ~25 minutes. Implementation time: 2 to 5 days for the mechanics, then ongoing for content.
- **License to reuse:** take it, fork it, hand it to your coding agent.

---

## 0. How to use this document

There are three ways in:

1. **Hand it to your coding agent.** Drop this file into your repo (as `AEO-GEO-PLAYBOOK.md` or in your `docs/` folder) and give your agent the prompt in [Section 15](#15-prompt-to-hand-your-coding-agent). It has enough specificity to act on without further instruction.
2. **Work the checklist.** [Section 14](#14-the-checklists) is a per-site and per-page checklist. Start there if you already know this material.
3. **Read it straight through** if you are the person who has to make the call on what to build.

A note on ordering: sections 2 through 5 are *mechanics*. They are cheap, they are a one-time cost, and without them nothing else works. Sections 8 and 9 are *content*, which is where the actual competitive advantage lives and where most of the ongoing effort goes. Do the mechanics first, but do not mistake them for the strategy.

**Caveat on shelf life:** this field moves fast. Crawler user-agent names, the status of `llms.txt`, and how each assistant weights signals all change. Re-verify the crawler list (Section 2) and the measurement approach (Section 13) every few months. The structural principles in sections 3 through 6 have been stable and are unlikely to move.

---

## 1. The mental model: this is not SEO

Classic SEO optimises to **rank a URL** in a list of ten blue links. The user then clicks, lands, and reads.

AEO (Answer Engine Optimization) and GEO (Generative Engine Optimization) optimise to be **extracted, quoted, and attributed inside a generated answer**. There is often no click at all. The win condition is being *named*.

Three consequences follow, and they change what you build:

### 1.1 The unit of optimization is the passage, not the page

A retrieval system chunks your page and pulls back two or three paragraphs, stripped of their neighbours. That chunk is what the model reads.

So every paragraph must **stand alone**. If a paragraph says "this approach reduces that cost by 40%," it is useless once separated from the heading that defined "this approach." Rewrite it as "Embedding an engineer on site reduces integration cost by 40%." Self-contained, subject named, number attached.

This one rule changes more of your copy than anything else in this document.

### 1.2 Quotability beats keyword density

Models cite what is *specific, attributable, dated, and numeric*. They cannot usefully quote "we deliver world-class outcomes for enterprise clients." They can quote "across 15 production engagements between 2024 and 2026, 60% were agentic systems and 27% were voice AI."

Ask of every claim on your site: **could a model repeat this sentence and attribute it to us, and would that sentence be useful to a reader?** If not, the sentence is decoration.

### 1.3 Entity clarity beats long-tail coverage

Because there is no click, the model has to know *who you are* to name you. That means one consistent entity: one legal name, one canonical description, one category phrase, corroborated in structured data and on third-party sites. Scattered synonyms dilute the association you are trying to build.

### 1.4 The four paths a model reaches your content

Design for all four, because they have different constraints:

| Path | How it works | What it demands of you |
|---|---|---|
| **Training crawl** | Bulk crawlers (`GPTBot`, `ClaudeBot`, `CCBot`, `Google-Extended`) archive pages for model training | Be crawlable, be in plain HTML, be *early*: today's page becomes next year's model weights |
| **Live retrieval** | The assistant runs a search at answer time and fetches the top results (`OAI-SearchBot`, `PerplexityBot`, `Claude-SearchBot`) | Be fast, be in the search index, have a chunk that directly answers the question |
| **Search grounding** | Google AI Overviews / Gemini ground on the regular Google index | Classic technical SEO still applies: Core Web Vitals, indexation, structured data |
| **User pastes a URL** | A human drops your link into a chat (`ChatGPT-User`, `Claude-User`, `Perplexity-User`) | Return complete content on a raw fetch with no JS, no consent wall, no bot challenge |

The fourth path is the most commonly broken and the easiest to test. See Section 3.

---

## 2. Pillar 1: Be retrievable

### 2.1 robots.txt: an explicit allowlist for AI crawlers

A wildcard `User-agent: * / Allow: /` is technically sufficient, but an **explicit per-bot allow** is worth writing anyway. It documents intent, it survives a future wildcard `Disallow` added by someone else, and it makes the policy reviewable.

```ts
// app/robots.ts  (Next.js App Router)
import type { MetadataRoute } from "next";

const AI_CRAWLERS = [
  // OpenAI
  "GPTBot",           // training crawl
  "OAI-SearchBot",    // ChatGPT search index
  "ChatGPT-User",     // user-initiated fetch (someone pasted your link)
  // Anthropic
  "ClaudeBot",
  "Claude-User",
  "Claude-SearchBot",
  "anthropic-ai",
  // Perplexity
  "PerplexityBot",
  "Perplexity-User",
  // Google / Apple / Amazon / Meta AI training controls
  "Google-Extended",
  "Applebot-Extended",
  "Amazonbot",
  "meta-externalagent",
  // Common Crawl: feeds many open datasets and downstream models
  "CCBot",
];

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/" },
      ...AI_CRAWLERS.map((userAgent) => ({ userAgent, allow: "/" })),
    ],
    sitemap: "https://example.com/sitemap.xml",
  };
}
```

Three things people get wrong here:

- **`Google-Extended` and `Applebot-Extended` are training opt-outs, not indexing controls.** Disallowing `Google-Extended` does not remove you from AI Overviews, which is served from the normal Googlebot index. Blocking it only removes you from Gemini training. Decide those two separately.
- **Your CDN can silently override robots.txt.** Cloudflare's "Block AI Scrapers and Crawlers" toggle, AWS WAF bot rules, Vercel's bot protection, and generic rate limits will return 403s to `GPTBot` and `ClaudeBot` no matter how welcoming your robots.txt is. **Check your WAF.** This is the single most common reason a technically perfect site is invisible to assistants.
- **Robots.txt is a request, not enforcement.** Allowing everything is a business decision about whether you want your content in models. For a marketing site the answer is almost always yes: you are trying to be cited.

### 2.2 sitemap.xml: generated from your data, never hand-written

A hand-maintained sitemap goes stale the day after you write it. Generate it from the same data that generates the pages.

```ts
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { articles } from "@/lib/articles";
import { caseStudies } from "@/lib/cases";

const SITE = "https://example.com";

export default function sitemap(): MetadataRoute.Sitemap {
  const now = new Date();
  const entry = (
    path: string,
    priority: number,
    changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"] = "weekly",
  ) => ({ url: `${SITE}${path}`, lastModified: now, changeFrequency, priority });

  return [
    entry("", 1),                                      // home
    entry("/product", 0.9),
    entry("/pricing", 0.9),
    entry("/faq", 0.7),
    entry("/research", 0.8),
    ...articles.map((a) => entry(`/research/${a.slug}`, 0.75, "monthly")),
    ...caseStudies.map((c) => entry(`/cases/${c.slug}`, 0.7)),
  ];
}
```

Use `lastModified` honestly. A sitemap that claims everything changed today, every day, teaches crawlers to ignore the field. Better: derive it per-entry from your content's real modification date (see Section 10).

### 2.3 llms.txt: cheap, useful, and not yet a standard

`/llms.txt` is a proposed convention: a Markdown file at your root that gives a model a curated, plain-text map of your site.

**Be clear-eyed about it:** no major AI crawler has publicly committed to consuming `llms.txt`, and it may never become a standard. Publish it anyway, because:

1. It costs an hour.
2. It is the single best thing to paste into a chat window when someone asks a model about your company.
3. Writing it forces you to state, in one page, what your company is and what every page is for. That exercise improves the rest of the site.
4. If the convention does land, you are already there.

Format: an H1 title, a `>` blockquote summary, then H2 sections, then a link list where every link has a one-line description of what is on that page.

```markdown
# Acme: real-time fraud detection for fintechs

> Acme provides real-time fraud detection as an API for fintechs and neobanks.
> Decisions return in under 40ms, models are trained per customer, and every
> decision is explainable to a regulator. Used by 120+ companies.

## What is real-time fraud detection?

Real-time fraud detection scores a transaction before it settles, using
behavioural, device, and network signals, and returns an approve / review /
decline decision inside the payment authorization window.

## What Acme offers

- Transaction scoring API: sub-40ms decisions on card, ACH, and wire flows.
- Case management: a review queue with full decision provenance.
- Model governance: per-decision explanations formatted for examiner review.

## How Acme is different

- Per-customer models, not one global model.
- Explainable by construction: every decline carries the reasons that produced it.
- Deploys in your VPC or ours.

## Pages

- [Acme home](https://example.com): real-time fraud detection as an API, the product, customers, and how it works.
- [Product](https://example.com/product): the scoring API, latency budget, integration paths, and supported rails.
- [Pricing](https://example.com/pricing): per-decision pricing, volume tiers, and what is included at each tier.
- [FAQ](https://example.com/faq): common buyer questions on latency, model training, data residency, and compliance.
- [2026 Fraud Benchmark Report](https://example.com/research/fraud-benchmark-2026): first-party data on decline rates and fraud loss across 1.2B transactions.

## Contact

- Book a call: https://cal.com/acme/30min
- Email: hello@example.com
```

Keep it in sync with a CI check (Section 12) or it will rot within a month.

### 2.4 Machine-readable feeds for anything list-shaped

If you publish anything that is fundamentally a list (jobs, events, products, datasets, changelog entries), also emit it as a feed. Aggregators ingest feeds, aggregators get crawled, and their pages become another surface where your entity appears.

We serve a job feed at `/jobs.xml`:

```ts
// app/jobs.xml/route.ts
import { JOBS, jobUrl, jobDescriptionHtml } from "@/lib/jobs";

export const dynamic = "force-static";   // build it once, serve from CDN

export async function GET() {
  const cdata = (s: string) => `<![CDATA[${s}]]>`;
  const jobs = JOBS.map((job) => {
    const fields: [string, string][] = [
      ["title", job.title],
      ["date", job.datePosted],
      ["url", jobUrl(job)],
      ["company", "Acme"],
      ["description", jobDescriptionHtml(job)],
    ];
    const inner = fields.map(([k, v]) => `    <${k}>${cdata(v)}</${k}>`).join("\n");
    return `  <job>\n${inner}\n  </job>`;
  }).join("\n");

  return new Response(
    `<?xml version="1.0" encoding="utf-8"?>\n<source>\n  <publisher>Acme</publisher>\n${jobs}\n</source>\n`,
    { headers: { "Content-Type": "application/xml; charset=utf-8" } },
  );
}
```

Same idea for an RSS/Atom feed on any blog or newsletter. Feeds are the cheapest syndication you will ever build.

### 2.5 ai-catalog.json: the manifest agents and registries fetch

`llms.txt` is for a model reading your site. The Agentic Resource Discovery (ARD) manifest is for an *agent* deciding whether to call you: it lists the machine resources you expose (an MCP server, an A2A agent card, feeds, knowledge files) with a media type, a URL, and the natural-language queries each one answers. Agent registries crawl it; Lighthouse's "Agent Discoverability" audit fetches it and validates the shape.

Publish it once at `public/.well-known/ai-catalog.json` and rewrite the other two addresses probes use onto it (`/.well-known/ard.json`, the spec's newer name, and the bare `/ai-catalog.json`):

```json
{
  "specVersion": "1.0",
  "host": { "displayName": "Acme", "identifier": "acme.com" },
  "entries": [
    {
      "identifier": "urn:air:acme.com:mcp:fraud",
      "displayName": "Acme fraud data (MCP server)",
      "type": "application/mcp-server-card+json",
      "url": "https://acme.com/.well-known/mcp-server-card.json",
      "description": "What the server knows, what it needs (keys, regions), where the endpoint is, and how results cite their source.",
      "capabilities": ["search_decisions", "loss_rate_benchmark"],
      "representativeQueries": [
        "what is the median card fraud loss rate for a fintech",
        "show me decline-threshold benchmarks for ACH"
      ]
    },
    { "identifier": "urn:air:acme.com:knowledge:llms-txt", "displayName": "Acme knowledge index", "type": "text/plain", "url": "https://acme.com/llms.txt", "description": "…", "representativeQueries": ["…", "…"] }
  ]
}
```

Rules that keep it valid:

- Every entry needs `identifier` in the form `urn:air:<your-domain>:<namespace>:<name>` (the validator rejects any other prefix), `displayName`, `type` (an IANA media type), exactly one of `url` or `data`, and a `description`. Give every entry 2 to 5 `representativeQueries`.
- An MCP entry's `type` is `application/mcp-server-card+json` and its `url` points at a small JSON card (name, description, `remotes` with the endpoint, tool names), not at the live endpoint. The validator lists the standard discovery types and marks anything else, including `text/plain` and `text/html`, as a low-severity notice; keep those entries only when the resource is worth an agent's attention.
- Only list what you actually serve. A registry that calls a dead endpoint drops you.
- **Never let an unknown file path fall through to a page.** Our catch-all sent `/ai-catalog.json` to the homepage with a 307, so the audit parsed HTML as JSON and failed the manifest it never found. A file-shaped path with no file behind it must answer 404 (Section 2.1 covers the dotfile case; the same rule applies to any extension).
- Validate the file in CI. Ours is checked in `check:seo` for shape and for every URL being on our own host.

The spec is a 2026 draft and field names may still move; re-verify against [agenticresourcediscovery.org/spec](https://agenticresourcediscovery.org/spec/) when you touch the file.

---

## 3. Pillar 2: Be readable with JavaScript disabled

**This is the highest-leverage item in the entire document.** If you do one thing, do this.

Googlebot renders JavaScript. Most AI crawlers, as a working assumption, **do not**, or do so with a budget you cannot rely on. A page that assembles its content client-side is, to a large fraction of the AI ecosystem, an empty page.

### 3.1 The test that settles it

```bash
# What a non-rendering crawler actually sees:
curl -sL https://example.com/ | wc -c                    # bytes of real HTML
curl -sL https://example.com/ | grep -c '<h1'            # should be exactly 1
curl -sL https://example.com/ | grep -o 'your key claim' # is your core copy there?

# Compare with a headline you know is on the page:
curl -sL https://example.com/pricing | grep -i "per decision"
```

If your core copy is not in that output, nothing else in this document matters yet. Also run it in a browser with JS disabled (DevTools > Command Palette > "Disable JavaScript") and read the page.

### 3.2 Prerender everything you can

Static generation is the whole fix. On our site every marketing route is prerendered to HTML at build time:

```
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
```

Aim for zero `ƒ` routes in your marketing surface. In Next.js App Router that means: keep pages as Server Components, push interactivity into small `"use client"` leaf components, and use `generateStaticParams` for every dynamic route.

The resulting HTML files are large in a *good* way. Our `/fde-job-market` page ships 870KB of prerendered HTML because the entire dataset is *in the document*, readable without a single request. That page is fully legible to a crawler that executes nothing.

### 3.3 The scroll-reveal trap, and the fix

Fade-in-on-scroll animations are the most common way sites accidentally hide their content. The usual implementation sets `opacity: 0` in CSS and adds a class via IntersectionObserver. To a crawler that does not run JS, **every section below the fold is invisible or, worse, looks like cloaking**.

The fix is one media query. Only hide content when scripting is actually available:

```css
/* Scroll reveal, only hides content when scripting is available,
   so crawlers and no-JS readers always get the full page. */
@media (scripting: enabled) {
  .reveal {
    opacity: 0;
    transform: translateY(16px);
    transition:
      opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
      transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
    transition-delay: var(--reveal-delay, 0ms);
    will-change: opacity, transform;
  }
  .reveal.is-visible {
    opacity: 1;
    transform: none;
  }
  @media (prefers-reduced-motion: reduce) {
    .reveal {
      transition: none;
      opacity: 1;
      transform: none;
    }
  }
}
```

`@media (scripting: enabled)` is supported across current browsers and evaluates to false in any non-JS context. Content is visible by default; the animation is an enhancement layered on top. The nested `prefers-reduced-motion` block covers accessibility at the same time.

The matching component stays trivial:

```tsx
"use client";
export function Reveal({ children, className = "", delay = 0 }) {
  const ref = useRef<HTMLElement | null>(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          entry.target.classList.add("is-visible");
          observer.unobserve(entry.target);   // one-shot: no repeated work on scroll
        }
      }
    }, { rootMargin: "0px 0px 14% 0px", threshold: 0.05 });
    observer.observe(el);
    return () => observer.disconnect();
  }, []);
  return <div ref={ref} className={`reveal ${className}`} style={{ "--reveal-delay": `${delay}ms` }}>{children}</div>;
}
```

### 3.4 Accordions, tabs, and modals must render their content

An FAQ accordion whose answers are fetched or mounted on click is invisible. Server-render every answer into the HTML and let the interaction only control *visibility*. Radix and shadcn accordions do this correctly by default; verify anyway:

```bash
curl -sL https://example.com/faq | grep -c "the first six words of a collapsed answer"
# expect >= 1 (and >= 2 if the answer is also in your FAQPage JSON-LD)
```

The same applies to tabbed content, "read more" expanders, and anything behind a modal. If a human can only reach it by clicking, a crawler can only reach it if the markup was already there.

### 3.5 Navigation must be real anchors

Hover-triggered dropdown menus are fine, as long as the links inside them are real `<a href>` elements present in the HTML. Drive the dropdown with CSS (`group-hover`, `group-focus-within`) rather than mounting the menu in JS, and the entire site graph stays crawlable:

```tsx
<div className="group relative">
  <button type="button" aria-haspopup="true">Product</button>
  <div className="invisible absolute left-0 top-full pt-2 opacity-0 transition-opacity
                  group-hover:visible group-hover:opacity-100
                  group-focus-within:visible group-focus-within:opacity-100">
    {items.map((s) => <a key={s.href} href={s.href}>{s.label}</a>)}
  </div>
</div>
```

`group-focus-within` is not decoration: it is what makes the menu keyboard-accessible.

### 3.6 Charts and images are opaque. Ship the numbers as text.

An SVG chart communicates nothing to a language model. If a figure matters, the numbers must also exist as text: a table, a caption, or a sentence in the surrounding prose.

We wrap every chart in a `<figure>` with a real `<figcaption>` naming the source, and state the headline number in the prose above it:

```tsx
<figure className="flex min-w-0 flex-col overflow-hidden rounded-2xl border p-6">
  <h3>Workload distribution across 15 engagements</h3>
  <p className="mt-1.5 text-[13px] text-foreground/60">
    Agentic systems 60%, voice AI 27%, the remainder split across RAG and evals.
  </p>
  <div className="mt-6 flex-1">{children}</div>
  <figcaption className="mt-4 font-mono text-[10.5px] uppercase tracking-[0.12em]">
    Source: Plank engagement census, June 2026
  </figcaption>
</figure>
```

Note that the caption states the finding in words. The chart is the illustration, not the evidence.

Corollary: **never put a claim only inside a screenshot.** Product screenshots, pricing tables rendered as images, and quote cards are all invisible content. Repeat the substance in text.

---

## 4. Pillar 3: Be fast

Speed matters for AEO for three concrete reasons, not just as general hygiene:

1. **Fetch timeouts.** Live-retrieval bots fetching your page mid-answer have a short patience budget. A slow TTFB can mean you are simply dropped from the answer.
2. **Crawl budget.** Faster pages get crawled more often and more deeply, which means fresher content in the index.
3. **Google grounding.** AI Overviews ground on the normal index, where Core Web Vitals remain a ranking input.

### 4.1 Budgets to hold yourself to

| Metric | Target | Why this number |
|---|---|---|
| TTFB | < 200ms | Achievable with static + CDN; nothing else buys as much |
| LCP (mobile) | < 2.0s | The "good" threshold is 2.5s; leave headroom for real networks |
| CLS | < 0.05 | The "good" threshold is 0.1; near-zero is easy if you reserve space |
| INP | < 200ms | Mostly free if you ship little JS |
| First-load JS | < 150KB gzipped | Every KB is parse and execute time on a mid-range Android |
| Render-blocking third parties | 0 | Analytics, chat widgets, and tag managers all load late or not at all |

Measure on **mobile, throttled**, not on your laptop. Lighthouse mobile preset or PageSpeed Insights field data.

### 4.2 The techniques that actually move the numbers

**Static generation with CDN delivery.** No per-request server work. This alone gets TTFB under 100ms.

**Self-host fonts, `display: swap`, woff2.** A third-party font host costs a DNS lookup, a TLS handshake, and a render-blocking dependency you do not control.

```tsx
import localFont from "next/font/local";

const sans = localFont({
  src: "../fonts/YourSans-Book.woff2",
  weight: "400",
  variable: "--font-sans",
  display: "swap",     // show fallback text immediately, swap when ready
});
```

**Animate the hero with transform only.** This is the subtle one. The standard "fade up the hero" animation starts at `opacity: 0`, which means your Largest Contentful Paint element is *not painted* until the animation runs. You have voluntarily added 300 to 600ms to your LCP. Animate transform only, keep opacity at 1 from the first frame:

```css
/* Above-the-fold entrance: transform-only (full opacity from the first frame) so
   the hero paints immediately and never delays LCP. Pure CSS, no JS gate. */
.hero-in { animation: hero-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes hero-in {
  from { transform: translateY(10px); }
  to   { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .hero-in { animation: none; }
}
```

Note also that this is pure CSS with no JS gate, so the hero animates even before hydration.

**Load analytics late, and only in production.**

```tsx
{process.env.NODE_ENV === "production" && GA_ID && (
  <>
    <Script src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} strategy="lazyOnload" />
    <Script id="ga-init" strategy="lazyOnload">{`
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());
      gtag('config', '${GA_ID}');
    `}</Script>
  </>
)}
```

`lazyOnload` fires during browser idle time after load. Analytics does not need to compete with your content for the main thread.

**Every responsive image gets an explicit `sizes`.** Without it the browser assumes `100vw` and downloads a desktop-sized image onto a phone.

```tsx
<Image
  src="/research/cover.webp"
  alt="Cover chart: workload distribution across 15 engagements"
  fill
  sizes="(min-width: 1024px) 360px, (min-width: 768px) 50vw, 100vw"
/>
```

**`priority` on the LCP image only.** Exactly one per page, usually the hero. Everything else stays lazy (the default). Marking many images `priority` is the same as marking none.

**Reserve space for every image to keep CLS at zero.** A fixed-size container plus a skeleton means the layout never moves:

```tsx
function SiteShot({ src, alt }: { src: string; alt: string }) {
  const [loaded, setLoaded] = useState(false);
  return (
    <div className="relative h-[112px] w-full overflow-hidden rounded-xl border bg-[#eef0f1]">
      {!loaded && <div aria-hidden="true" className="absolute inset-0 animate-pulse bg-[#e6e9eb]" />}
      <Image
        src={src} alt={alt} fill sizes="300px" loading="lazy"
        onLoad={() => setLoaded(true)}
        className={`object-cover transition-opacity duration-500 ${loaded ? "opacity-100" : "opacity-0"}`}
      />
    </div>
  );
}
```

**Use webp or avif for screenshots and photography.** Typically 30 to 50% smaller than the PNG at the same perceived quality.

**Passive scroll listeners.** If you have a scroll handler (a sticky header, for instance), mark it passive so it never blocks scrolling:

```ts
window.addEventListener("scroll", onScroll, { passive: true });
```

---

## 5. Pillar 4: Handle mobile properly

Most AI-assistant traffic that does convert to a visit arrives on a phone. Beyond that, Google's index is mobile-first, so the mobile rendering *is* the canonical version of your page.

### 5.1 The non-negotiables

```html
<meta name="viewport" content="width=device-width, initial-scale=1" />
```

Never add `maximum-scale=1` or `user-scalable=no`. Blocking pinch-zoom is an accessibility failure and does not fix your layout. (Next.js emits the correct tag by default.)

### 5.2 Fluid type instead of breakpoint steps

Headings that jump at breakpoints look broken between them. `clamp()` scales continuously:

```tsx
<h1 className="text-[clamp(2.25rem,5.2vw,4.25rem)]">…</h1>
<h2 className="text-[clamp(2rem,4.5vw,3.25rem)]">…</h2>
```

Read it as: never smaller than 2.25rem, never bigger than 4.25rem, 5.2% of viewport width in between.

### 5.3 Kill horizontal overflow at the source

Body-level horizontal scrolling is the most common mobile defect, and it is almost always one of two causes.

**Cause 1: a flex or grid child that refuses to shrink.** Flex items default to `min-width: auto`, so a long unbroken string (a URL, a code token, a wide table) pushes the container wider than the screen. The fix is `min-w-0` on the child:

```tsx
<div className="flex gap-4">
  <div className="min-w-0 flex-1">{/* long content shrinks correctly now */}</div>
</div>
```

We use `min-w-0` in 19 places. It is almost always the answer.

**Cause 2: genuinely wide content.** Tables and charts that cannot shrink should scroll *inside their own container*, never the page:

```tsx
<div className="overflow-x-auto rounded-2xl border">
  <table className="w-full min-w-[720px]">…</table>
</div>
```

### 5.4 Text wrapping primitives

Two CSS properties, almost free, that noticeably improve small-screen typography:

- `text-balance` on headings: distributes words evenly across lines, preventing a one-word orphan on line three.
- `text-pretty` on body copy: avoids orphans and improves rag.

```tsx
<h1 className="text-balance leading-[1.04] tracking-[-0.022em]">…</h1>
<p className="text-pretty text-[16px] leading-relaxed">…</p>
```

We apply these 155 times across the site. It is the cheapest visual quality win available.

### 5.5 Carousels: native touch scroll with CSS snap

Do not build a JS carousel. Use a scroll container with CSS scroll-snap, which gives you momentum scrolling, accessibility, and keyboard support for free. Add arrow buttons for desktop mouse users on top:

```tsx
<ul className="no-scrollbar flex snap-x snap-mandatory gap-4 overflow-x-auto py-3">
  {items.map((item) => (
    <li key={item.id} className="snap-center shrink-0 w-[280px]">…</li>
  ))}
</ul>
```

```css
@layer utilities {
  .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
  .no-scrollbar::-webkit-scrollbar { display: none; }
}
```

Critically, all the cards are in the DOM, so the carousel content is crawlable.

### 5.6 Mobile navigation

Requirements, in order of how often they are missed:

1. **Every desktop nav destination is reachable on mobile.** Hover dropdowns do not exist on touch. Flatten their children into the mobile menu with section labels.
2. **Correct ARIA on the toggle:** `aria-expanded`, `aria-label` that changes with state, and `aria-label` on each `<nav>` to distinguish "Main" from "Mobile."
3. **Close the menu on route change**, or a client-side navigation leaves the menu open over the new page:
   ```tsx
   const pathname = usePathname();
   useEffect(() => { setMenuOpen(false); }, [pathname]);
   ```
4. **Touch targets at 40px minimum, 44px for primary actions.** In Tailwind, `size-10` (40px) for icon buttons, `h-11` (44px) for the primary CTA.
5. **Generous vertical padding on menu rows** (`py-3`) rather than tightly packed links.

### 5.7 Handle the very small screen deliberately

Dense metadata rows turn into a wrapped mess below about 380px. Rather than let them degrade, hide them at that width and let the headline carry the message:

```tsx
<div className="hidden ... min-[381px]:flex">
  <span>Build · product engineering</span>
  <span aria-hidden="true">/</span>
  <span>Deploy · forward deployment</span>
</div>
```

Test at 320px (iPhone SE) and 390px (iPhone 15). Those two widths catch nearly everything.

### 5.8 Anchor offsets under a fixed header

If you use a sticky header, in-page anchors will scroll their target under it. One utility fixes every anchor on the site:

```tsx
<section id="methodology" className="scroll-mt-24">…</section>
```

---

## 6. Pillar 5: Be structured (JSON-LD)

Structured data is how you tell a machine what your page *is*, in a form that requires no inference. It is the difference between a model guessing you are a company and a model knowing your legal name, founders, services, and social profiles.

### 6.1 Use one `@graph` per page, with `@id` cross-references

Do not scatter five separate `<script type="application/ld+json">` tags. Emit **one** graph per page and link the nodes by `@id`. This lets you declare an entity once and reference it everywhere:

```ts
// lib/seo.ts: small helpers, reused by every page
export const SITE_URL = "https://example.com";

export const org = {
  "@type": "Organization",
  name: "Acme",
  legalName: "Acme Technologies, Inc.",
  url: SITE_URL,
} as const;

export function breadcrumb(items: { name: string; path: string }[]) {
  return {
    "@type": "BreadcrumbList",
    itemListElement: items.map((it, i) => ({
      "@type": "ListItem",
      position: i + 1,
      name: it.name,
      item: `${SITE_URL}${it.path}`,
    })),
  };
}

export function faqPage(faqs: { q: string; a: string }[]) {
  return {
    "@type": "FAQPage",
    mainEntity: faqs.map((f) => ({
      "@type": "Question",
      name: f.q,
      acceptedAnswer: { "@type": "Answer", text: f.a },
    })),
  };
}

export function jsonLd(graph: unknown[]): string {
  return JSON.stringify({ "@context": "https://schema.org", "@graph": graph });
}
```

Then a page composes what it needs:

```tsx
const pageJsonLd = jsonLd([
  breadcrumb([{ name: "Home", path: "/" }, { name: "FAQ", path: "/faq" }]),
  { "@type": "WebPage", url: `${SITE_URL}/faq`, name: "Acme FAQ", about: org },
  faqPage(allFaqs),
]);

export default function Page() {
  return (
    <>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: pageJsonLd }} />
      {/* … */}
    </>
  );
}
```

### 6.2 The home page graph: declare your entity once, properly

This is the most important block of structured data you will write. It is the canonical machine-readable definition of your company.

```ts
const jsonLd = {
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": ["Organization", "ProfessionalService"],
      "@id": `${SITE_URL}/#organization`,          // the anchor everything else references
      name: "Acme",
      legalName: "Acme Technologies, Inc.",
      alternateName: "real-time fraud detection API",   // your category phrase
      url: SITE_URL,
      email: "hello@example.com",
      description:
        "Acme provides real-time fraud detection as an API for fintechs and neobanks: sub-40ms decisions on card, ACH, and wire flows, with per-customer models and regulator-ready explanations for every decision.",
      slogan: "Fraud decisions in under 40ms, explainable to a regulator",
      sameAs: [                                     // entity corroboration, see 11.1
        "https://github.com/acme",
        "https://www.linkedin.com/company/acme",
        "https://x.com/acme",
        "https://www.crunchbase.com/organization/acme",
      ],
      founder: [
        { "@type": "Person", name: "Jane Doe", sameAs: ["https://x.com/janedoe"] },
        { "@type": "Person", name: "Sam Roe",  sameAs: ["https://x.com/samroe"] },
      ],
    },
    {
      "@type": "Service",
      name: "Transaction scoring API",
      serviceType: "Fraud detection as a service",
      provider: { "@id": `${SITE_URL}/#organization` },   // cross-reference, not duplication
      description: "Sub-40ms approve / review / decline decisions on card, ACH, and wire transactions.",
      url: `${SITE_URL}/product`,
    },
    {
      "@type": "WebPage",
      url: SITE_URL,
      name: "Real-time fraud detection API | Acme",
      about: { "@id": `${SITE_URL}/#organization` },
      potentialAction: { "@type": "ScheduleAction", target: "https://cal.com/acme/30min", name: "Book a call" },
    },
  ],
};
```

Note `description` and `slogan`: these are the sentences a model is most likely to reuse when asked "what is Acme?" Write them as the answer you want repeated back.

### 6.3 Which types are worth the effort

We ship 28 distinct schema types across 26 page files. Ranked by value:

| Type | Use it on | Why it earns its place |
|---|---|---|
| `Organization` / `ProfessionalService` | Home, referenced everywhere | The root entity. Nothing works without it |
| `FAQPage` + `Question` / `Answer` | Any page with Q&A | Direct question-to-answer mapping, the highest-value pairing for AEO |
| `Service` | One per offering page | Names each thing you sell as a distinct entity |
| `Article` | Every research piece, blog post, brief | Carries author, publisher, and the dates that drive freshness |
| `Dataset` + `DataDownload` | Any page backed by original data | Explicitly signals "there is citable data here," with a machine-readable copy |
| `DefinedTerm` | One page per core term you want to own | Wins the enormous "what is X?" query class |
| `BreadcrumbList` | Every page below the root | Gives the model site hierarchy and context |
| `Person` | Founders, authors, team | Author identity is an authority signal |
| `JobPosting` | Careers pages | Feeds Google Jobs and job aggregators |
| `HowTo` + `HowToStep` | Process and onboarding pages | Matches "how do I…" queries directly |
| `ItemList` / `CollectionPage` | Index and listing pages | Tells the model the page is a set, and what is in it |
| `Course` / `EducationalOccupationalProgram` | Training, certification | Only if you actually run programs |
| `TechArticle` | Deep technical write-ups | Signals a developer audience |

### 6.4 Rules that keep structured data honest

1. **Never assert in JSON-LD what is not visible on the page.** Schema that contradicts the rendered content is spam, it risks a manual action, and it teaches models to distrust your markup. The `Answer.text` in your `FAQPage` should be the same sentence a human reads.
2. **Use absolute URLs everywhere.** Set `metadataBase` so relative OG paths resolve, and hardcode the full origin in JSON-LD.
3. **One `FAQPage` per page maximum.**
4. **Dates in ISO 8601** (`2026-08-10`), and keep `dateModified` truthful (Section 10).
5. **Validate every type before shipping**: [Rich Results Test](https://search.google.com/test/rich-results) and [validator.schema.org](https://validator.schema.org). Then add a CI check that the tag simply exists on every page (Section 12), because the common failure is not malformed schema, it is a page that quietly shipped with none.

---

## 7. Pillar 6: Question-and-answer architecture

You asked specifically about this, and it deserves its own section, because it is the structure that maps most directly onto how assistants work.

**Why it works:** retrieval matches a user's question against your content. A literal question, written as a heading, is the single best chunk anchor you can create. The paragraph beneath it is pre-formatted to be the answer.

### 7.1 Three layers, all of them worth building

**Layer 1: inline FAQ blocks on every commercial page.** Three to six questions at the bottom of each product, pricing, or solution page, specific to *that* page. These capture the mid-funnel objections that a buyer would actually voice.

**Layer 2: a dedicated `/faq` hub.** Fifteen to thirty questions covering the whole buying decision: what you are, who it is for, pricing, security, IP, integration, how you compare to alternatives. This is the page a model retrieves when someone asks a diligence question about you.

**Layer 3: standalone pages for the biggest question classes.** Some questions deserve their own URL, their own H1, and their own schema:

- `/what-is-<term>` with `DefinedTerm` + `Article`. The "what is X" class is one of the highest-volume query shapes in chat, and it is where you can define the category on your own terms.
- `/<your-thing>-vs-<alternative>` for genuine comparison queries. Heavily searched, chronically under-served by vendors who are afraid to name competitors. Be scrupulously fair here: a model that catches you misrepresenting an alternative will not treat you as a reliable source.
- `/<term>/interview-guide`, `/<term>/pricing-guide`, or similar practical sub-pages that answer the second question a person asks after the first.

Here is the definitional pattern in full:

```ts
const DEFINITION =
  "A forward-deployed engineer (FDE) is a software engineer who embeds inside a customer's organization to make a product actually work in their environment. They write production code against the customer's real data and systems, and they own whether it delivers a result, not just whether it ships.";

const pageJsonLd = jsonLd([
  breadcrumb([{ name: "Home", path: "/" }, { name: "Forward-deployed engineer", path: PATH }]),
  {
    "@type": "DefinedTerm",
    name: "Forward-deployed engineer",
    alternateName: ["FDE", "Forward deployed software engineer", "Forward-deployed engineering"],
    description: DEFINITION,
    url: PAGE_URL,
    inDefinedTermSet: PAGE_URL,
  },
  {
    "@type": "Article",
    headline: "What is a forward-deployed engineer?",
    url: PAGE_URL,
    datePublished: "2026-07-25",
    dateModified: "2026-07-25",
    publisher: { "@type": "Organization", name: "Plank", url: SITE_URL },
    about: { "@type": "DefinedTerm", name: "Forward-deployed engineer" },
  },
  faqPage(faqs),
]);
```

The same `DEFINITION` string is used in the H1's opening paragraph, in the `DefinedTerm`, and as the first FAQ answer. One canonical sentence, three surfaces.

### 7.2 How to write a question

**Write it the way a person types it into a chatbot**, not the way a marketer would title a section.

| Write this | Not this |
|---|---|
| "How much does a forward-deployed engineer cost?" | "Investment and engagement models" |
| "How is this different from a solutions engineer?" | "Differentiation" |
| "Who owns the IP for work you produce?" | "Intellectual property policy" |
| "How fast can you start?" | "Onboarding timeline" |

Every question ends in a question mark. We enforce that in CI, which sounds petty until you notice how quickly headings drift back into noun phrases.

### 7.3 How to write an answer

Six rules, in priority order:

1. **Answer completely in the first sentence.** Assume the reader stops there, because the model often will.
2. **Make it self-contained.** No "it," "this," or "as mentioned above." The answer will be read without the question attached to it. Name the subject: not "It takes about three weeks," but "A typical Acme integration takes about three weeks from kickoff to first production decision."
3. **Name your entity inside the answer.** The model needs to know who to attribute. At least one answer in every set should contain your company name.
4. **40 to 90 words.** Long enough to be substantive, short enough to be quoted whole.
5. **Include a specific: a number, a duration, a named technology, a date.** Specifics are what make an answer worth citing over a competitor's vaguer one.
6. **Mirror the exact text into `FAQPage` JSON-LD.** Never schema-only, never schema-that-differs.

A worked example:

> **How fast can Acme start returning decisions?**
>
> Most Acme integrations return their first production decision within three weeks: one week for sandbox integration against your transaction schema, one week of shadow-mode scoring alongside your existing system, and one week of tuned rollout. Teams already on a standard card processor often reach shadow mode in four days. Acme provides the integration engineer for all three weeks at no additional cost.

Specific, self-contained, names the entity, carries three numbers and a duration, and is quotable in full.

### 7.4 The accordion requirement

If the FAQ is an accordion, the answers must be in the HTML (see Section 3.4). Verify it:

```bash
curl -sL https://example.com/faq | grep -c "Most Acme integrations return their first"
```

Expect at least 2: once in the visible markup, once in the JSON-LD.

---

## 8. Pillar 7: Metadata and OG cards

Two audiences: the search index, and the link preview that appears whenever your URL is shared, including inside chat clients that render unfurls.

### 8.1 The full per-page contract

```tsx
// app/research/fraud-benchmark-2026/page.tsx
import type { Metadata } from "next";

const SITE_URL = "https://example.com";
const PAGE_URL = `${SITE_URL}/research/fraud-benchmark-2026`;

export const metadata: Metadata = {
  title: "The 2026 Fraud Benchmark: 1.2B Transactions Analysed | Acme Research",
  description:
    "First-party analysis of 1.2 billion card, ACH, and wire transactions across 120 fintechs: median fraud loss rates, false-positive costs, and how decline thresholds moved in 2026.",
  alternates: { canonical: PAGE_URL },
  openGraph: {
    type: "article",                       // "website" for non-article pages
    url: PAGE_URL,
    siteName: "Acme",
    title: "The 2026 Fraud Benchmark",     // punchier than the SEO title
    description:
      "1.2 billion transactions across 120 fintechs: median fraud loss, false-positive cost, and where decline thresholds landed in 2026.",
    publishedTime: "2026-08-01T00:00:00.000Z",
    modifiedTime: "2026-08-10T00:00:00.000Z",
  },
  twitter: {
    card: "summary_large_image",
    title: "The 2026 Fraud Benchmark",
    description: "1.2B transactions across 120 fintechs: fraud loss, false-positive cost, decline thresholds.",
  },
};
```

And once, in the root layout:

```tsx
export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),        // makes relative OG image paths resolve to absolute
  title: "…", description: "…",
  authors: [{ name: "Acme", url: SITE_URL }],   // renders <meta name="author"> + <link rel="author">
  creator: "Acme",
  publisher: "Acme",
  alternates: { canonical: SITE_URL },
  robots: {
    index: true,
    follow: true,
    googleBot: { index: true, follow: true, "max-image-preview": "large" },
  },
};
```

`max-image-preview: large` is what permits large thumbnails in Google Discover and rich result surfaces. It is one line and it is frequently missing.

`authors` is the other line that is frequently missing. AEO checkers score "author information" as an authority signal and look for it in the head, not in your JSON-LD. Set it once in the root layout as your organization and every page inherits it; an article page with named people can still list them as `Person` in its `Article` schema.

### 8.2 Length budgets

| Field | Budget | Reason |
|---|---|---|
| `title` | 50 to 60 chars | Google truncates around 60. Front-load the distinctive words |
| `description` | 70 to 160 chars | Under 70 is thin; Google shows about 155 to 160 and AEO checkers flag anything longer. Enforced 70 to 160 in CI, on the rendered HTML; a description that had to be truncated to fit also fails |
| `openGraph.title` | 40 to 70 chars | Rendered in a card, not a SERP. Can be punchier than the SEO title |
| `openGraph.description` | 100 to 200 chars | Two lines in most unfurl renderers |

**Write the description as an answer, not a teaser.** "Learn how Acme can transform your fraud operations" is worthless to a model. "Acme returns approve / review / decline decisions in under 40ms on card, ACH, and wire, with per-customer models and per-decision explanations" is a paragraph a model can lift verbatim. The meta description is often the *only* text a retrieval system sees before deciding whether to fetch the page.

### 8.3 Generate a distinct OG image per route

One generic OG image across the whole site is a missed opportunity. Every route on our site has its own generated card: about 25 OG image routes, all rendered at build time from a shared template.

```tsx
// lib/og.tsx: one template, used by every route
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { ImageResponse } from "next/og";

export const OG_SIZE = { width: 1200, height: 630 };
export const OG_CONTENT_TYPE = "image/png";

export async function ogImage({ eyebrow, title, subtitle, footer = "Acme" }) {
  // satori (which powers next/og) cannot read woff2, so ship a .ttf copy for OG only.
  const font = await readFile(join(process.cwd(), "src/fonts/YourSans-Book.ttf"));

  // Fit the title to its length so long headlines never overflow the card.
  const titleSize = title.length > 34 ? 60 : title.length > 22 ? 76 : 92;

  return new ImageResponse(
    (
      <div style={{
        width: "100%", height: "100%", display: "flex", flexDirection: "column",
        justifyContent: "space-between", backgroundColor: "#ffffff", color: "#101314",
        padding: "64px 72px", fontFamily: "Sans",
      }}>
        <div style={{ display: "flex" }}>{/* your logo as inline SVG */}</div>
        <div style={{ display: "flex", flexDirection: "column" }}>
          <div style={{ display: "flex", fontSize: 24, letterSpacing: "0.14em",
                        textTransform: "uppercase", color: "#2323ff" }}>{eyebrow}</div>
          <div style={{ display: "flex", marginTop: 22, fontSize: titleSize,
                        letterSpacing: "-0.02em", lineHeight: 1.05 }}>{title}</div>
          <div style={{ display: "flex", marginTop: 24, fontSize: 30,
                        color: "#5b5d5e", maxWidth: 920 }}>{subtitle}</div>
        </div>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "center",
                      height: 64, borderRadius: 12, backgroundColor: "#ececff",
                      border: "1px solid #cdcdff", color: "#2323ff", fontSize: 24 }}>{footer}</div>
      </div>
    ),
    { ...OG_SIZE, fonts: [{ name: "Sans", data: font, weight: 400, style: "normal" }] },
  );
}
```

Each route is then five lines:

```tsx
// app/research/fraud-benchmark-2026/opengraph-image.tsx
import { ogImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

export const alt = "The 2026 Fraud Benchmark: 1.2B transactions analysed";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function Image() {
  return ogImage({
    eyebrow: "Acme Research",
    title: "The 2026 Fraud Benchmark",
    subtitle: "1.2 billion transactions across 120 fintechs.",
  });
}
```

Practical notes learned the hard way:

- **satori cannot parse woff2.** Keep a `.ttf` of your display face in the repo purely for OG rendering.
- **Size the title by string length**, as above, or long headlines will overflow and clip.
- **satori supports a subset of CSS.** Every element needs an explicit `display: flex`; there is no grid, and no cascade. Write flat inline styles.
- **`export const alt`** is real alt text on a real image. Write it.
- For dynamic routes, put `opengraph-image.tsx` inside the `[slug]` folder and it generates per entry at build time.

Test the result with the [OpenGraph debugger](https://www.opengraph.xyz/), by pasting the link into Slack, and by pasting it into a chat assistant.

---

## 9. Pillar 8: Authority through original research

This is where the compounding happens, and it is the part most companies skip because it looks like it belongs to a different department.

### 9.1 Why original research is the strongest GEO lever

A model asked a specific question needs a source for the specific claim. If your page is **the only place a particular number exists**, and someone asks a question that number answers, you are the citation. There is no competitor to rank above, because there is no competing source.

That produces a compounding loop that ordinary marketing pages do not get:

1. You publish a number nobody else has.
2. Journalists, newsletters, and analysts quote it, because it is the only data available.
3. Those articles are crawled, and now your entity is associated with the topic across many domains.
4. That corpus becomes training data, and the association is baked into model weights.
5. The model recommends you for the topic even when it is not retrieving your page live.

Steps 3 through 5 are the moat. A landing page cannot buy them at any price.

### 9.2 Six formats, ranked by return on effort

**1. The census or aggregation (highest ROI).** Find something public but un-aggregated, aggregate it, and publish the result with the underlying data. We built a first-party census of open forward-deployed engineer roles by harvesting company ATS boards, Y Combinator, Wellfound, LinkedIn, Indeed, and VC job boards, then tagging every posting by vertical, stage, region, seniority, and posted salary. Nobody else had that view. It is now our most-cited page.

Look for: job postings, pricing pages, public filings, GitHub activity, app store listings, regulatory registers, conference agendas. The data is sitting there. The work is collecting and tagging it.

**2. First-party operational data.** Aggregate, anonymised statistics from your own product or delivery work. We publish a census of our own engagements: workload mix, sector distribution, and stack diversity across 15 documented projects. Legally simple if you aggregate, strip identifiers, and get customer sign-off on anything nameable.

**3. Per-entity dossier pages.** A dedicated page per significant company, product, or person in your space, with metrics, leadership, structure, and signals. These capture the enormous "tell me about company X" query class, which almost nobody serves well. We run these at `/research/<company-slug>`, generated from a single typed dataset so all six stay consistent and updatable.

**4. The recurring brief.** A monthly synthesis of news in your category, where each development is paired with your analysis of what it means. This is your freshness engine (Section 10) and it builds the habit of publishing. Each edition gets its own URL under an index page, so `/brief/august-2026` accumulates rather than replacing.

**5. The benchmark or technical deep dive.** Reproducible work with open artifacts. Ours documents building an open-source multi-agent template that solves a public reasoning benchmark, with the repo linked. These earn developer credibility and inbound links, which is a different and complementary kind of authority.

**6. The survey.** Highest effort and the slowest, because you need a distribution channel before you can field it. Do this after one of the above has already built you an audience.

### 9.3 The anatomy of a citable report

A report that gets cited has structure that a report that does not, lacks. Every one of these is deliberate:

**A headline number, in the title, the H1, and the meta description.** Not "The State of Fraud, 2026" but "The 2026 Fraud Benchmark: 1.2B Transactions Analysed." The number is the hook a model retrieves against.

**A dated observation window, stated visibly.** "Collected 2026-07-04 from 412 company career pages." Undated data is uncitable data, because a model has no way to know if it is still true.

**A methodology section.** Sources, sample size, inclusion criteria, and, critically, **limitations**. Stating what your data cannot show is a strong credibility signal, and it is the section analysts read first.

**Per-fact confidence labels.** On our research dossiers every metric carries a confidence level, rendered as a coloured dot with a legend:

```ts
export type Confidence = "confirmed" | "estimated" | "unconfirmed";

export type Metric = {
  label: string;
  value: string;
  sub?: string;
  confidence: Confidence;
};
```

This is unusual and it is worth the effort. It lets you publish useful estimates without overclaiming, and it signals epistemic care to both human readers and models.

**Every external claim linked to a named source.** Store the source with the claim in your data model, so it is impossible to render a fact without its attribution:

```ts
export type NewsItem = {
  headline: string;
  analysis: string;
  source: { name: string; url: string };   // required, not optional
};
```

**A machine-readable download, declared in schema.** Publish the raw data as JSON or CSV and point `Dataset` at it. This is an explicit invitation to be used:

```ts
{
  "@type": "Dataset",
  name: "Acme fraud benchmark dataset, 2026",
  description: "1.2B transactions across 120 fintechs, aggregated by rail, MCC, decline reason, and month. Observed 2026-08-01.",
  url: PAGE_URL,
  datePublished: "2026-08-01",
  dateModified: "2026-08-01",
  distribution: {
    "@type": "DataDownload",
    encodingFormat: "application/json",
    contentUrl: `${SITE_URL}/fraud-benchmark-2026.json`,
  },
  creator: { "@type": "Organization", name: "Acme" },
  license: "https://creativecommons.org/licenses/by/4.0/",
  temporalCoverage: "2025-01/2026-06",
}
```

**An open license.** CC-BY 4.0 says: reuse this, just attribute us. That is exactly the trade you want. Restrictive licensing on marketing research is self-defeating; the citation *is* the return.

**Numbers as text, not only as charts.** Repeated from Section 3.6 because it is the most common failure in research pages specifically. Every chart needs its finding stated in prose and, ideally, a table of the underlying values.

**Inline data references.** Link a figure in the prose back to the chart it came from, with the source named. A pure-CSS hover tooltip does this with no client JS:

```tsx
function DataRef({ href, source, children }) {
  return (
    <a href={href} className="group relative underline decoration-dotted underline-offset-4">
      {children}
      <span className="pointer-events-none absolute left-0 top-full z-10 hidden w-max
                       rounded-md border bg-white px-2 py-1 text-[11px] group-hover:block">
        {source}
      </span>
    </a>
  );
}
```

**`Article` + `Dataset` in one graph**, with `author` and `publisher` as your Organization, and honest `datePublished` / `dateModified`.

**A stable URL you keep updating.** Update `/research/fraud-benchmark` in place each year rather than publishing `/research/fraud-benchmark-2027` at a new URL. Accumulated authority stays where the links point. Keep an in-page changelog so readers can see what moved.

### 9.4 The research hub

Give all of it one parent at `/research`, with `CollectionPage` + `ItemList` schema, a card per report showing type, date, and a one-line summary of the *finding* rather than the topic. Link it from your primary nav, not from the footer. It should read like a publication, because that is what you are asking models to treat it as.

### 9.5 Distribution: the half that is not engineering

Publishing is not distribution, and steps 2 through 5 of the loop in 9.1 only happen if humans encounter the work. Minimum viable distribution for each report:

- A LinkedIn post from a founder, with the single most surprising chart.
- Hacker News or the relevant subreddit, if the data is genuinely novel. Lead with the finding, not the brand.
- Direct email to five to ten journalists and newsletter writers who cover the space, with the number in the subject line.
- Submit the dataset to any relevant aggregator or awesome-list.
- Cite your own report from your other pages, so internal links point at it.

Assistants weight what is *discussed*, not just what is published.

---

## 10. Pillar 9: Freshness, kept honest

Assistants prefer recent sources, especially for anything about a market, a price, or a technology. But a `dateModified` you bump by hand is a date you will forget to bump, and a date you bump on every deploy is a lie that stops carrying information.

### 10.1 Derive the date from git

The modification date already exists: it is the last commit that touched the page's content files. Read it from git and write it into the source:

```js
// Effective date: if any of the page's content files have uncommitted changes,
// the page is being edited now, so the date is today; otherwise it is the date
// of the most recent commit touching those files.
const git = (cmd) => execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8" }).trim();

const DATED_PAGES = [
  {
    route: "/research/fraud-benchmark-2026",
    file: "src/app/research/fraud-benchmark-2026/page.tsx",
    deps: ["src/app/research/fraud-benchmark-2026/page.tsx", "src/lib/benchmark-data.ts"],
  },
];

for (const { route, file, deps } of DATED_PAGES) {
  const dirty = git(`status --porcelain -- ${deps.join(" ")}`);
  const effective = dirty ? today() : git(`log -1 --format=%cs -- ${deps.join(" ")}`);
  if (!/^\d{4}-\d{2}-\d{2}$/.test(effective)) continue;   // no git history: leave it alone

  const src = read(file);
  const current = src.match(/dateModified:\s*"(\d{4}-\d{2}-\d{2})"/)?.[1];
  if (!current || current === effective) continue;

  if (checkOnly) {
    fail(`${route}: dateModified is ${current}, should be ${effective}`);
  } else {
    writeFileSync(file, src.replace(/dateModified:\s*"\d{4}-\d{2}-\d{2}"/, `dateModified: "${effective}"`));
    console.log(`• ${route}: dateModified ${current} -> ${effective}`);
  }
}
```

Run it in two modes: as a fixer locally (`npm run check:seo`) and with `--check` in CI, where drift fails the build. The date is then structurally incapable of being wrong.

For pages whose date comes from data rather than from edits (a dataset with an observation date), derive it from the data instead and skip the git logic entirely:

```ts
datePublished: dataset.observed,
dateModified: dataset.observed,
```

### 10.2 Show the date to humans too

Models read visible text more reliably than they parse schema. Render the date on the page, near the title:

```tsx
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
  Published {fmtDate(article.datePublished)} · Updated {fmtDate(article.dateModified)}
</p>
```

### 10.3 Put the dates in the head of every page, not just research

Checkers and most answer-engine crawlers read publication dates from two places: a visible `<time>` element, or `article:published_time` / `article:modified_time` meta tags in the head. They do **not** read `datePublished` out of JSON-LD. A marketing page with a perfect `WebPage` schema and no head tags scores "no publication date".

Rather than hand-wiring each page, emit the tags once from the root layout. The dates come from the same git-derived table the sitemap uses, plus a second table for the first-publish date:

```tsx
// components/site/page-dates.tsx
"use client";
import { usePathname } from "next/navigation";
import { pageDates } from "@/lib/page-dates";   // longest-prefix lookup into the two JSON tables

export function PageDates() {
  const dates = pageDates(usePathname());
  if (!dates) return null;
  // React hoists <meta> into <head> from anywhere in the tree, during SSR too.
  return (
    <>
      <meta property="article:published_time" content={dates.published} />
      <meta property="article:modified_time" content={dates.modified} />
    </>
  );
}
```

Render it in the root layout body (ours is `<PageMeta />`). Have the same component emit one `WebPage` JSON-LD node with `datePublished`, `dateModified`, `author` and `publisher` pointing at your Organization (nested in full, with its founders as `Person`), so every page, not just articles, carries author, date, and entity schema. Several checkers read a single script block and want a top-level `@type`; a page whose only JSON-LD is an `@graph` reads to them as untyped. Two details matter:

- Use a `property` attribute, which is what the Open Graph article convention and every parser expects. Next's `metadata.other` renders `name` instead, which parsers miss.
- Derive `published` from the commit that **added** the route file (`git log --diff-filter=A --follow`), and never rewrite a value once recorded, so shallow CI clones and file renames cannot move it. A page that states its own `datePublished: "YYYY-MM-DD"` literal is the editorial record and wins over git, because a page drafted in place can predate its publication by months.

Dynamic families (`/cases/[slug]`, `/companies/[slug]`) resolve by longest prefix to their parent's dates, with the family's data module listed among the parent's sources so a data refresh moves `modified`.

### 10.4 Publish on a cadence

A monthly or quarterly recurring format does more for freshness than sporadic bursts, because it produces a predictable stream of new URLs, new `lastmod` values, and new reasons for other people to link to you. Pick a cadence you can actually hold and hold it.

---

## 11. Pillar 10: Entity consistency and off-site presence

Everything so far is on your own site. A meaningful share of what a model believes about you comes from everywhere else.

### 11.1 Corroborate your entity with `sameAs`

`sameAs` on your Organization is how you tell a model that these accounts are all the same entity. List every profile you control:

```ts
sameAs: [
  "https://www.linkedin.com/company/acme",
  "https://github.com/acme",
  "https://x.com/acme",
  "https://www.crunchbase.com/organization/acme",
  "https://www.wikidata.org/wiki/Q…",     // if you have one
  "https://www.g2.com/products/acme",
]
```

Then make the description on each of those profiles **match your on-site description**. Not similar: the same. Divergent self-descriptions across profiles are the fastest way to blur your entity.

### 11.2 Pick one canonical phrase and never vary it

Choose the phrase you want to own for your category and use it **verbatim** everywhere: title tags, H1s, JSON-LD `alternateName`, `llms.txt`, LinkedIn bio, conference abstracts, press quotes. Ours is "FDE as a service," and we enforce its presence in CI.

The mechanism is simple. Models learn associations between entities and phrases from co-occurrence. Five near-synonyms split that signal five ways. One phrase, repeated across many contexts and many domains, becomes an association strong enough that the model produces your name when the phrase comes up.

Rules for choosing it: three to five words, contains the category noun, natural enough to say out loud, and not already owned by a larger competitor.

### 11.3 Get discussed where models read

Models cite the wider web, not only vendor sites. Where practical:

- **Review platforms** (G2, Capterra, Clutch) with real, current profiles.
- **Comparison and listicle articles** that name your category. If none exist, one of your research pieces can be the honest version.
- **Community answers** on Reddit, Hacker News, and Stack Overflow, from real accounts, genuinely answering questions. Do not astroturf: it is detectable, it damages trust, and communities punish it.
- **Podcasts and conference talks**, which produce transcripts, which are text, which get crawled.
- **Your customers' own pages.** A customer case study on *their* site is third-party corroboration you cannot manufacture.

---

## 12. Make it durable: a CI gate

Every practice in this document decays. A new page ships without JSON-LD, a description drifts to 40 characters, someone adds a second `<h1>`. The only durable fix is a check that fails the build.

Ours is a single dependency-free Node script, run as `npm run check:seo`, wired into CI. Here it is, generalised:

```js
#!/usr/bin/env node
// Dependency-free AEO/GEO/SEO gate. Run: node scripts/check-seo.mjs [--check]
//
// Pillars:
//   1. robots:      AI crawlers allowed, sitemap referenced.
//   2. llms.txt:    present, well-formed, lists every public page.
//   3. JSON-LD:     every public page renders application/ld+json.
//   4. Answer-first: one <h1>, sane meta description, FAQs are questions,
//                     the target entity phrase appears on the home page.
//   5. Freshness:   hard-coded dateModified stays in sync with git.
//   6. Authority:   every indexable page renders an author meta tag and
//                   article:published_time / article:modified_time.

import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { execSync } from "node:child_process";

const ROOT = process.cwd();
const errors = [];
const fail = (m) => errors.push(m);
const read = (p) => readFileSync(join(ROOT, p), "utf8");

// ---- configure these three for your site ---------------------------------
const TARGET_ENTITY = "real-time fraud detection";   // your canonical phrase
const HOST = "example.com";
const REQUIRED_BOTS = [
  "GPTBot", "OAI-SearchBot", "ChatGPT-User",
  "ClaudeBot", "anthropic-ai",
  "PerplexityBot", "Google-Extended", "CCBot",
];

// Every public, indexable page. h1File covers pages whose <h1> lives in a
// shared component rather than in the route file.
const PAGES = [
  { route: "/",        file: "src/app/page.tsx", h1File: "src/components/hero.tsx" },
  { route: "/product", file: "src/app/product/page.tsx" },
  { route: "/pricing", file: "src/app/pricing/page.tsx" },
  { route: "/faq",     file: "src/app/faq/page.tsx" },
  { route: "/research",file: "src/app/research/page.tsx" },
];
// --------------------------------------------------------------------------

// 1. robots ----------------------------------------------------------------
{
  const robots = read("src/app/robots.ts");
  for (const bot of REQUIRED_BOTS) {
    if (!robots.includes(bot)) fail(`robots: missing AI crawler "${bot}"`);
  }
  if (!/sitemap/i.test(robots)) fail("robots: no Sitemap reference");
}

// 2. llms.txt --------------------------------------------------------------
{
  if (!existsSync(join(ROOT, "public/llms.txt"))) {
    fail("llms.txt: missing public/llms.txt");
  } else {
    const llms = read("public/llms.txt");
    if (!llms.startsWith("# ")) fail('llms.txt: must start with "# "');
    if (!/\n> /.test(llms)) fail('llms.txt: missing "> " summary blockquote');
    if (!llms.includes(TARGET_ENTITY)) fail(`llms.txt: missing entity "${TARGET_ENTITY}"`);
    for (const { route } of PAGES) {
      const url = route === "/" ? `${HOST})` : `${HOST}${route})`;
      if (!llms.includes(url)) fail(`llms.txt: no entry linking to ${route}`);
    }
  }
}

// 3. JSON-LD + 4. answer-first, per page -----------------------------------
for (const { route, file, h1File } of PAGES) {
  if (!existsSync(join(ROOT, file))) { fail(`${route}: source file missing (${file})`); continue; }
  const src = read(file);
  const componentSrc = h1File ? read(h1File) : "";

  if (!src.includes("application/ld+json") && !componentSrc.includes("application/ld+json")) {
    fail(`${route}: no application/ld+json structured data`);
  }

  const h1Src = h1File ? componentSrc : src;
  const h1s = (h1Src.match(/<h1[\s>]/g) || []).length;
  if (h1s !== 1) fail(`${route}: expected exactly one <h1>, found ${h1s}`);

  if (route !== "/") {   // home's description lives in layout.tsx
    const metaBlock = src.slice(src.indexOf("export const metadata"));
    const m = metaBlock.match(/description:\s*\n?\s*"([^"]+)"/);
    if (!m) {
      fail(`${route}: no metadata description`);
    } else if (m[1].length < 70 || m[1].length > 200) {
      fail(`${route}: meta description length ${m[1].length} out of range (70-200)`);
    }
  }
}

// The home page must carry the target entity verbatim.
{
  const layout = read("src/app/layout.tsx");
  const home = read("src/app/page.tsx");
  if (!layout.includes(TARGET_ENTITY) && !home.includes(TARGET_ENTITY)) {
    fail(`home: target entity "${TARGET_ENTITY}" not found in metadata or JSON-LD`);
  }
}

// FAQ entries must be question-shaped.
for (const file of ["src/lib/content.ts"]) {
  for (const [, q] of read(file).matchAll(/\bq:\s*"([^"]+)"/g)) {
    if (!q.trim().endsWith("?")) fail(`${file}: FAQ not a question -> "${q}"`);
  }
}

// House style: no em/en dashes in user-facing copy (en dash OK in numeric ranges).
for (const file of PAGES.map((p) => p.file)) {
  read(file).split("\n").forEach((line, i) => {
    if (/—/.test(line)) fail(`em dash at ${file}:${i + 1}`);
    if (/–/.test(line) && !/\d\s*–\s*\d/.test(line)) fail(`en dash at ${file}:${i + 1}`);
  });
}

// 5. Freshness: see Section 10.1 for the git-derived dateModified block.

// Report -------------------------------------------------------------------
if (errors.length) {
  console.error(`\n✗ check-seo failed with ${errors.length} issue(s):\n`);
  for (const e of errors) console.error(`  - ${e}`);
  process.exit(1);
}
console.log("✓ check-seo passed");
```

```json
// package.json
{ "scripts": { "check:seo": "node scripts/check-seo.mjs" } }
```

Notes on adapting it:

- **It is deliberately regex-based over source files, not a crawler.** That makes it instant, dependency-free, and runnable pre-commit. It catches structural regressions, which is what actually breaks. Pair it with a real crawl (Section 13) less often.
- **The em-dash rule is house style, not an algorithm requirement.** We keep it because heavy em-dash use is a well-known tell of machine-written copy, and we would rather our writing not read that way. Drop it if you disagree; keep some style gate.
- Extend it with whatever your site cares about: alt text on every image, canonical on every page, no `TODO` in shipped copy, internal links resolving.

Wire it into CI:

```yaml
# .github/workflows/ci.yml
- run: npm ci
- run: npm run check:seo -- --check
- run: npm run build
```

---

## 13. Verify and measure

### 13.1 Ship-time verification, from the command line

```bash
SITE=https://example.com

# One h1 per page, and content present without JS
curl -sL $SITE/ | grep -c '<h1'
curl -sL $SITE/pricing | grep -o 'per decision'

# Structured data is on the page and parses
curl -sL $SITE/ \
  | grep -o '<script type="application/ld+json"[^>]*>[^<]*' \
  | sed 's/.*>//' | python3 -m json.tool | head -40

# Robots and sitemap resolve, and the sitemap is not empty
curl -sI $SITE/robots.txt | head -1
curl -sL $SITE/sitemap.xml | grep -c '<url>'

# llms.txt is served as text, not downloaded
curl -sI $SITE/llms.txt | grep -i content-type

# The OG image route actually renders a PNG
curl -sI $SITE/opengraph-image | grep -iE 'content-type|content-length'

# Confirm you are not being blocked by your own WAF
curl -sI -A "GPTBot" $SITE/ | head -1        # expect 200
curl -sI -A "ClaudeBot" $SITE/ | head -1     # expect 200
curl -sI -A "PerplexityBot" $SITE/ | head -1 # expect 200
```

That last block is the one people skip and the one that most often reveals the real problem.

### 13.2 Tooling

| Check | Tool |
|---|---|
| Structured data validity | [Rich Results Test](https://search.google.com/test/rich-results), [validator.schema.org](https://validator.schema.org) |
| Core Web Vitals, field data | PageSpeed Insights, Chrome UX Report |
| Core Web Vitals, lab | Lighthouse, mobile preset, throttled |
| Indexation and coverage | Google Search Console, Bing Webmaster Tools |
| OG card rendering | opengraph.xyz, plus pasting into Slack and into a chat assistant |
| Rendering without JS | DevTools > Command Palette > "Disable JavaScript" |

Register with **Bing Webmaster Tools** as well as Google. ChatGPT's search has been built on Bing's index, so Bing indexation is a direct AEO input, and almost nobody bothers.

### 13.3 Measuring whether any of it worked

There is no rank tracker for this yet. Three practical proxies:

**1. Server logs.** Grep your access logs or CDN analytics for AI crawler user agents. Crawl frequency is your leading indicator: it moves weeks before citations do.

```bash
grep -aoE "GPTBot|ClaudeBot|PerplexityBot|OAI-SearchBot|Claude-SearchBot|CCBot" access.log \
  | sort | uniq -c | sort -rn
```

**2. Referral traffic from assistant domains.** In GA4 or your analytics, segment referrals from `chatgpt.com`, `claude.ai`, `perplexity.ai`, and `gemini.google.com`. Low volume, high intent, and growing. Track the trend, not the absolute number.

**3. A citation panel you run by hand.** Write down 15 to 25 questions a buyer would actually ask, covering your category, your competitors, and your own name. Once a month, run every question in a **fresh, logged-out session** in ChatGPT, Claude, Perplexity, and Google AI Mode, and record: were you mentioned, were you cited with a link, and which page. Keep it in a spreadsheet with a date column.

It is manual and it feels unscientific, but it is the only measurement that reflects what your buyers actually see. The month-over-month trend is the signal.

Expect a lag. Retrieval-based surfaces (Perplexity, ChatGPT search) can pick up a new page within days. Training-based recall takes model generations, measured in many months.

### 13.4 The failure modes to rule out first

Before optimising anything, confirm none of these apply. Each one silently nullifies most of the work above:

| Failure | Why it kills you | How to spot it |
|---|---|---|
| **CDN or WAF blocking AI bots** | Perfect robots.txt, 403 in practice | `curl -A "GPTBot"` returns non-200 |
| **Content rendered client-side** | The crawler sees an empty shell | `curl` the page and read the output |
| **Cookie or consent wall** | The crawler only ever sees the interstitial | Fetch with no cookies and read what comes back |
| **Claims only inside screenshots** | Images are opaque to text models | Search your HTML for the claim as text |
| **Charts with no textual equivalent** | The finding does not exist in text | Same |
| **Infinite scroll with no paginated URLs** | Only the first page is ever reachable | Check for `?page=2` or equivalent |
| **`noindex` shipped from staging** | Total invisibility | `curl -sL $SITE/ \| grep -i noindex` |
| **Near-duplicate pages for the same intent** | Splits your entity signal | Compare titles and H1s across the site |
| **Copy with no falsifiable specifics** | Literally unquotable | Try to extract one citable sentence per page |
| **JSON-LD contradicting the visible page** | Spam risk, distrust of your markup | Diff `Answer.text` against rendered copy |
| **A catch-all route redirecting unknown URLs to `/`** | Produces soft 404s and hides broken links | Request a nonsense path and check the status code |

That last one deserves a caveat, because we do it on our own site: our catch-all redirects unmatched paths to the home page. It is a deliberate trade for a small marketing site where we would rather never show a dead end. **For most sites, return a real 404** with a helpful page. Soft 404s waste crawl budget and mask link rot. Know which trade you are making.

---

## 14. The checklists

### 14.1 Site-wide, do once

- [ ] `robots.txt` explicitly allows every AI crawler, and references the sitemap
- [ ] CDN / WAF / bot protection verified not to block those crawlers (`curl -A`)
- [ ] `sitemap.xml` generated from data, with honest `lastmod`
- [ ] `/llms.txt` published, listing every public page with a one-line description
- [ ] `/.well-known/ai-catalog.json` published (Section 2.5), listing the MCP server, feeds, and knowledge files; validated in CI
- [ ] Unknown file-shaped paths (`/anything.json`, `/feed.xml`, `/.env`) answer 404, never a redirect to a page
- [ ] Every marketing route prerendered to static HTML
- [ ] Full content readable with JavaScript disabled
- [ ] Scroll animations gated behind `@media (scripting: enabled)`
- [ ] `metadataBase` set; canonical URL on every page
- [ ] `authors`, `creator`, `publisher` set in the root metadata, so every page renders `<meta name="author">`
- [ ] `article:published_time` / `article:modified_time` rendered in every page's head from git-derived dates (Section 10.3), checked in CI
- [ ] `robots: { googleBot: { "max-image-preview": "large" } }` in the root metadata
- [ ] Organization JSON-LD with `legalName`, `description`, `sameAs`, `founder`
- [ ] One canonical category phrase chosen, and enforced in CI
- [ ] Fonts self-hosted, woff2, `display: swap`
- [ ] Analytics and third-party scripts loaded with a lazy strategy
- [ ] `viewport` meta correct, with zoom permitted
- [ ] `check:seo` script written and wired into CI
- [ ] `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` set on every response (HSTS too, if the host does not add it); scanners dock trust for these and a `headers()` block in `next.config` is two minutes
- [ ] Registered with Google Search Console **and** Bing Webmaster Tools
- [ ] Machine-readable feed published for anything list-shaped

### 14.2 Every new page

- [ ] Exactly one `<h1>`, and it states what the page is
- [ ] `title` 50 to 60 chars; `description` 70 to 160 chars, written as an answer
- [ ] Route added to the dated-route table so author and dates render in its head (`curl -s URL | grep -c 'article:published_time'` returns 1)
- [ ] Content images carry real `alt`; decorative images use `alt=""` deliberately (checkers count them as missing, and that is fine)
- [ ] `alternates.canonical` set
- [ ] `openGraph` block: `type`, `url`, `siteName`, `title`, `description`
- [ ] `twitter.card: "summary_large_image"`
- [ ] A dedicated `opengraph-image.tsx` with real `alt` text
- [ ] JSON-LD `@graph` with `BreadcrumbList`, the right page type, and `@id` links to your Organization
- [ ] The core answer appears in the first 100 words of visible copy
- [ ] Every paragraph is self-contained and names its subject
- [ ] Three to six page-specific FAQs, question-shaped, mirrored into `FAQPage` schema
- [ ] Every claim carries a number, a date, or a named source
- [ ] LCP image marked `priority`; every other image lazy with explicit `sizes`
- [ ] Tested at 320px and 390px with no horizontal body scroll
- [ ] Added to `sitemap.ts` and to `llms.txt`
- [ ] `curl` shows the core copy present without JS

### 14.3 Every research report

- [ ] A headline number in the title, H1, and meta description
- [ ] A visible observation date and time window
- [ ] A methodology section including limitations
- [ ] Confidence labels on individual facts where appropriate
- [ ] Every external claim linked to a named source
- [ ] Every chart's finding also stated in prose, with a `<figcaption>` naming the source
- [ ] Raw data published as JSON or CSV
- [ ] `Article` + `Dataset` + `DataDownload` JSON-LD, with an open license
- [ ] `datePublished` / `dateModified` accurate and git-synced
- [ ] Listed on the `/research` hub with `CollectionPage` + `ItemList`
- [ ] A stable URL you will update in place rather than superseding
- [ ] A distribution plan executed, not just written

---

## 15. Prompt to hand your coding agent

Paste this, with this file in the repo:

> Read `AEO-GEO-PLAYBOOK.md` in this repo. It describes how to make a website legible and citable to AI assistants.
>
> Work through it against this codebase in this order, and stop after each phase so I can review:
>
> **Phase 1, audit.** Produce a table of every public route with: does it have exactly one `<h1>`, a metadata description of 70 to 200 characters, a canonical URL, an OG block, an `opengraph-image`, and JSON-LD. Then `curl` the deployed site with JS disabled and report which pages have their core copy missing from the raw HTML. Also `curl -A "GPTBot"` and `curl -A "ClaudeBot"` against the deployed origin and report the status codes. Do not change anything yet.
>
> **Phase 2, mechanics.** Fix what the audit found, in this order: (a) crawler access, (b) no-JS readability, including moving any scroll-reveal CSS behind `@media (scripting: enabled)`, (c) robots.ts and sitemap.ts generated from data, (d) `public/llms.txt`, (e) per-page metadata and canonical URLs, (f) a shared OG image template plus one `opengraph-image.tsx` per route, (g) a JSON-LD `@graph` per page built from shared helpers in `src/lib/seo.ts`.
>
> **Phase 3, guardrails.** Write `scripts/check-seo.mjs` following Section 12, configured for this site's routes and our canonical category phrase, which is: `<YOUR PHRASE>`. Add it as `npm run check:seo` and wire it into CI with `--check`.
>
> **Phase 4, performance and mobile.** Apply Sections 4 and 5: audit every `next/image` for an explicit `sizes` and correct `priority` usage, ensure the hero animation is transform-only, move analytics to a lazy strategy, add `min-w-0` and `overflow-x-auto` wherever content can overflow, and verify no horizontal body scroll at 320px and 390px. Report Lighthouse mobile scores before and after.
>
> **Phase 5, content scaffolding.** Propose, but do not write, (a) 20 FAQ questions phrased the way a buyer would type them into a chatbot, split between page-level FAQs and a `/faq` hub, (b) two or three definitional pages for terms we should own, and (c) one original-research report we could produce from data we already have or could collect, following Section 9.
>
> Constraints: do not add dependencies without asking. Do not put anything in JSON-LD that is not visible on the page. Keep every change server-rendered. Match the existing code style.

---

## Appendix: crawler quick reference

| User agent | Operator | Purpose |
|---|---|---|
| `GPTBot` | OpenAI | Training crawl |
| `OAI-SearchBot` | OpenAI | Search index for ChatGPT |
| `ChatGPT-User` | OpenAI | User-initiated fetch of a pasted link |
| `ClaudeBot` | Anthropic | Training crawl |
| `Claude-SearchBot` | Anthropic | Search index |
| `Claude-User` | Anthropic | User-initiated fetch |
| `anthropic-ai` | Anthropic | Legacy identifier, keep allowing it |
| `PerplexityBot` | Perplexity | Index crawl |
| `Perplexity-User` | Perplexity | User-initiated fetch |
| `Google-Extended` | Google | Gemini training opt-out control. Does **not** affect Search or AI Overviews |
| `Applebot-Extended` | Apple | Apple Intelligence training control |
| `Amazonbot` | Amazon | Alexa and Amazon AI |
| `meta-externalagent` | Meta | Meta AI training |
| `CCBot` | Common Crawl | Feeds many open datasets and downstream models |

Re-verify this list periodically. Operators add and rename agents, and each publishes its own documentation and IP ranges.

---

## One-paragraph summary

Ship static HTML that contains your whole argument without JavaScript, fast enough that a crawler never times out and correct enough that a phone never scrolls sideways. Structure the argument as questions a buyer would actually ask, answered completely in the first sentence, mirrored into JSON-LD, with one consistent name for your category everywhere. Give every page its own metadata and its own generated social card. Then publish original data that nobody else has, date it, source it, license it for reuse, and distribute it, because being the only source of a number is the only durable way to become the answer. Finally, put a script in CI that fails the build when any of that regresses, because all of it decays by default.

---

*Prepared by the Plank team. The patterns here run in production on [joinplank.com](https://joinplank.com). Questions: hello@joinplank.com*
